ProjectsmahiSocialite

Mahi

Framework

A TypeScript application framework for building APIs and services on Node.js — container, ORM, router, queues, mail and more.

Socialite

@mahiframework/socialite is OAuth sign-in with third-party providers. A port of the ideas in laravel/socialite, adapted to a framework with no session bag, whose drivers are cached singletons.

import { Socialite } from "@mahiframework/socialite";

// GET /auth/github/redirect
return HttpResponse.redirect(await Socialite.driver("github").redirect(request));

// GET /auth/github/callback
const githubUser = await Socialite.driver("github").user(request);

const user = await findOrCreateUser(githubUser);
await Auth.login(request, String(user.id), { guard: "web" });

The package authenticates against the provider and stops there. It does not touch your database, does not log anyone in, and ships no routes. Turning "here is a verified GitHub account" into "this person is signed in" needs decisions only the app can make — which table holds the provider id, whether to auto-register, which guard, where to redirect afterwards — and a framework that guessed all four would be wrong more often than useful. The controller above is the whole integration.

Nine providers ship, plus generic OpenID Connect in a separate package. The driver seam is the same one a third-party package or your own app registers through, and the behavioural contract every driver must satisfy ships with the package — every built-in driver runs it.

driverProviderDefault scopes
bitbucketBitbucketemail
facebookFacebookemail
githubGitHubuser:email
gitlabGitLab, incl. self-managedread_user
googleGoogleopenid profile email
linkedinLinkedInopenid profile email
slackSlackopenid email profile
twitchTwitchuser:read:email
xX (formerly Twitter)users.read users.email tweet.read
oidcAny OpenID Connect issueropenid profile email

oidc needs @mahiframework/socialite-oidc.

Not installed by default

npm install @mahiframework/socialite

Then add the provider to config/app.ts:

import { SocialiteServiceProvider } from "@mahiframework/socialite";

export const providers: ServiceProviderClass[] = [
  EventsServiceProvider,
  DatabaseServiceProvider,
  // ...
  EncryptionServiceProvider,
  AuthServiceProvider,
  SocialiteServiceProvider,  // ← here
  // ...
  HttpServiceProvider,

  AppServiceProvider,
];

One ordering constraint, and it is real:

  • After EncryptionServiceProvider — register() resolves SIGNER_TOKEN to derive the "socialite" signing purpose, which signs the OAuth state cookie.

Nothing else. The package contributes no routes, commands, migrations, models or listeners, and a root-relative redirect is resolved lazily on first use, so HttpServiceProvider may come on either side.

Configuration

// config/socialite.ts
import type { SocialiteConfig } from "@mahiframework/socialite";
import type { Env } from "./env.js";

export function socialiteConfig(env: Env): SocialiteConfig {
  return {
    providers: {
      github: {
        clientId: env.GITHUB_CLIENT_ID,
        clientSecret: env.GITHUB_CLIENT_SECRET,
        redirect: "/auth/github/callback",
      },
    },
  };
}
KeyTypeNotes
clientIdstringRequired.
clientSecretstringRequired.
redirectstringRequired. Absolute, or root-relative and resolved through the URL generator.
driverstring?Defaults to the config name.
scopesstring[]?Replaces the driver's defaults. See below.
pkceboolean?Add PKCE (S256). Off by default.
parametersRecord<string, string>?Extra authorization-URL parameters.

Missing a required key is a boot-time failure naming every key it is missing at once, not a failure when somebody clicks the button. Laravel Socialite validates on resolve; this is a deliberate divergence, on the grounds that a typo in .env should be found by the deploy.

There is deliberately no default. There is no default OAuth provider — a bare Socialite.driver() means the call site lost track of which button the user clicked, so it throws.

Configured scopes replace, they do not merge

github: { scopes: ["repo"] }  // exactly ["repo"], not ["user:email", "repo"]

Laravel Socialite merges here, so configuring ["repo"] on GitHub silently yields ["user:email", "repo"], which surprises people constantly. The fluent methods keep Socialite's semantics — .scopes() merges, .setScopes() replaces — so the explicit call reads how it behaves.

Running one driver twice

providers is keyed by config name; driver names the implementation. Omit it and the name is the driver, which is the same shorthand auth.guards uses.

providers: {
  live:    { driver: "github", clientId: env.GH_LIVE_ID, /* ... */ },
  staging: { driver: "github", clientId: env.GH_STAGING_ID, /* ... */ },
}

Both are GithubSocialiteDriver; Socialite.driver("staging") resolves the second. An app with two GitHub Apps, or two Keycloak realms, needs this.

redirect() queues one short-lived signed cookie per provider, carrying the state and, under PKCE, the verifier. user() reads it, checks it, and clears it — single-use, so a replayed callback fails.

cookie: {
  secure: true,        // default; opt out for plain-HTTP local development
  path: "/",
  ttlSeconds: 600,     // how long a user may sit on the consent screen
  prefix: "host",      // optional: a __Host- cookie
}

SameSite is fixed at Lax and is not configurable. The provider returns the user by a cross-site top-level GET, and Strict withholds cookies on exactly that navigation — so a Strict state cookie makes every login fail with InvalidStateError. Lax is the correct value here, and the one footgun worth removing from the config surface.

The cookie is signed with Signer.for("socialite"), so a signature minted by any other consumer of the app key — a signed URL, a session cookie — will not verify as OAuth state.

See why this is a cookie and not a session value.

The driver

Every method that needs a request takes one. Nothing is stored on the driver, because Manager caches it for the process lifetime and one instance is shared by every concurrent request.

MethodReturnsPurpose
redirect(request)Promise<string>The authorization URL; queues the state cookie.
user(request)Promise<SocialiteUser>Verify the callback, exchange the code, fetch the user.
userFromToken(token)Promise<SocialiteUser>A user from a token already held.
refreshToken(token)Promise<SocialiteToken>Exchange a refresh token.
getName()string"GitHub".
getWebsite()string"https://github.com".
getScopes()readonly string[]Scopes this driver will request.

redirect() returns a URL, not a response, so an app can render an interstitial or hand the URL to a SPA instead of redirecting.

Fluent methods return a copy

const driver = Socialite.driver("github");

driver.scopes(["repo"]);        // does nothing — the return value was dropped
const wide = driver.scopes(["repo"]);  // this is the configured driver
MethodEffect
scopes(s)Merge into the existing set.
setScopes(s)Replace the existing set.
with(params)Extra authorization-URL parameters.
redirectUrl(url)Override redirect_uri.
stateless()Issue no state and check none.
withPkce()Add an S256 challenge.

This diverges from Laravel Socialite, where these mutate and return $this, and the reason is the singleton: a mutating scopes(["repo"]) in one handler would widen the scopes of every subsequent login in the process, which is a privilege-escalation bug no type checker can see. PendingRequest in http-client is immutable for the same reason.

stateless() removes the callback's CSRF protection and is only appropriate where the round trip is not browser-mediated.

The user

interface SocialiteUser<TRaw = Record<string, unknown>> {
  readonly id: string;
  readonly nickname: string | null;
  readonly name: string | null;
  readonly email: string | null;
  readonly avatar: string | null;
  readonly raw: TRaw;
  readonly token: SocialiteToken;
}

interface SocialiteToken {
  readonly token: string;
  readonly refreshToken: string | null;
  readonly expiresIn: number | null;
  readonly approvedScopes: readonly string[];
}

raw is the provider's verbatim payload, typed per driver:

const user = await Socialite.driver("github").user(request);

user.raw.node_id;  // string, checked at compile time

Four deliberate differences from Laravel Socialite:

  • id is always a string. GitHub returns a number, Google a string; normalising at the boundary beats every call site guessing. It is unique within its provider and nowhere else, so store it alongside which provider it came from.
  • Credentials are nested under token. Socialite puts the same four fields on both its user and its Token DTO and keeps them in sync by hand in two places.
  • No attributes bag and no magic getter. Socialite's __get serves undeclared keys from a parallel array; a Proxy would reproduce it exactly and destroy inference, which is most of the reason to be in TypeScript. raw does the job with types.
  • approvedScopes is [] when the provider sends no scope, not [""]. PHP's explode(",", "") yields one empty string, and so does JS's "".split(",").

Provider notes

Mostly the drivers are uninteresting — endpoints, a scope list, a field mapping. These are the exceptions, and each is faithful to the provider rather than to convention.

GitHub

  • Scopes are comma-separated. Most OAuth 2.0 providers want spaces; GitHub accepts commas, so the driver does not override the default.
  • The email is a second API call. GET /user omits email unless the account has a public one, so the driver asks GET /user/emails for the primary verified address — but only when user:email is in scope. Every failure of that call yields email: null rather than failing the login, because a user whose email is unreadable is still a successfully authenticated user.
  • Authentication uses GitHub's legacy Authorization: token <t> scheme, not Bearer.

Bitbucket

Same two-call email shape as GitHub, looking for the primary confirmed address. id is Bitbucket's uuid, braces included.

GitLab

Set host for a self-managed instance:

gitlab: {
  host: "https://gitlab.example.com",
  clientId: env.GITLAB_CLIENT_ID,
  // ...
}

getWebsite() then reports that host, so a login page links to the right place. The driver reads /api/v4/user with a bearer token — Socialite ports /api/v3/user with the token as a query parameter, and GitLab removed the v3 API in 2018.

Google

Override access_type to get a refresh token, which Google issues only on first authorization:

await Socialite.driver("google").with({ access_type: "offline", prompt: "consent" }).redirect(request);

refreshToken() carries the original refresh token forward when Google omits a new one, so a second refresh still works.

X (formerly Twitter)

The most demanding driver, and the one that justifies three of the base's extension points: PKCE is mandatory (X rejects a request without a code_challenge, so it is not an opt-in you can forget), client credentials go as HTTP Basic on the token request, and the authorization URL uses RFC 3986 query encoding.

email requires the users.email scope and X's approval of your app; it is null otherwise.

Slack and LinkedIn

Both use their provider's OpenID Connect sign-in flow, which is what each documents for "sign in with" and what Socialite calls SlackOpenIdProvider / LinkedInOpenIdProvider.

Socialite's other SlackProvider drives the bot-installation flow, whose user_scope split and authed_user unwrapping exist to install an app into a workspace rather than to identify a person. Its other LinkedInProvider requests r_liteprofile/r_emailaddress, which LinkedIn retired. Neither is ported.

For the Slack workspace a user signed in from:

import { slackTeamId } from "@mahiframework/socialite";

const team = slackTeamId(user.raw);

A helper rather than a field on SocialiteUser, because "which tenant" is Slack-specific and does not generalise.

Twitch

Requires a Client-Id header alongside the bearer token, and returns its user inside a data array — raw is the unwrapped object. nickname is Twitch's login (the canonical handle) and name is display_name; Socialite uses display_name for both and loses the handle.

Despite the openid scope, these are not OIDC drivers

google, slack and linkedin request openid but ignore the id_token entirely, taking identity from the userinfo endpoint. That is sound because their issuer is a hardcoded constant: there is no issuer-substitution surface for id_token validation to defend against, and the code was exchanged directly with that issuer over TLS.

The moment the issuer comes from config, the reasoning collapses. See below.

Generic OpenID Connect

npm install @mahiframework/socialite-oidc
import { SocialiteOidcServiceProvider } from "@mahiframework/socialite-oidc";

// config/app.ts — before or after SocialiteServiceProvider, either works
SocialiteOidcServiceProvider,
// config/socialite.ts
providers: {
  work: {
    driver: "oidc",
    issuer: "https://keycloak.example.com/realms/main",
    clientId: env.OIDC_CLIENT_ID,
    clientSecret: env.OIDC_CLIENT_SECRET,
    redirect: "/auth/work/callback",
    label: "Work SSO",
  },
}

Then it is an ordinary driver: Socialite.driver("work").redirect(request).

A separate package because correct id_token validation needs jose, and an app that only wanted GitHub login should not carry a JWT library. This is the same split media and media-sharp make. It registers in boot(), so it may be listed before or after SocialiteServiceProvider.

Why this is a different problem

google and slack ignore the id_token and that is fine, because their issuer is a constant. Here the issuer is configuration, and everything below follows from that one difference:

  • Discovery from {issuer}/.well-known/openid-configuration, with the document's own issuer checked against the configured one. Without that check a substituted document relocates the authorization, token and JWKS endpoints at once.
  • Every endpoint the document names pinned to the issuer's origin. See below.
  • Signature against the JWKS key named by the token's kid.
  • An alg allow-list (RS256 by default), never the token's own claim about itself. This is what rejects alg: none and the RS256→HS256 confusion where an attacker HMACs a token using the issuer's public key as the secret.
  • iss exactly the configured issuer.
  • aud containing the client id; a multi-valued aud additionally requires azp to be this client.
  • exp/nbf/iat within a clock tolerance (60s by default).
  • nonce equal to the one minted on the redirect, single-use. This is the replay defence; state is CSRF protection and the two are not interchangeable.
  • userinfo.sub === id_token.sub, per OIDC Core §5.3.2. Without it, one user's token pairs with another's profile — and the profile is what your app keys an account on.
  • PKCE on by default, mandatory in OAuth 2.1 and the FAPI profiles.

All of it runs inside user(), so an app gets it and cannot forget it.

Configuration

KeyDefaultNotes
issuer—Required.
algorithms["RS256"]Accepted signing algorithms.
clockToleranceSeconds60Skew allowed on exp/nbf/iat.
discoveryTtlSeconds3600How long the metadata is cached.
jwksCooldownSeconds30Minimum gap between JWKS refetches.
idTokenOnlyfalseSkip userinfo; take identity from the token.
allowEndpointHosts[]Hosts besides the issuer's whose endpoints are acceptable.
labelthe issuer's hostDisplay name.

The endpoints come from the document, not from you

The issuer URL is yours: you typed it into config, or an admin did. The four endpoints are not. .well-known/openid-configuration supplies authorization_endpoint, token_endpoint, jwks_uri and userinfo_endpoint, and this server afterwards fetches three of them and redirects a browser to the fourth. A hostile or compromised issuer would otherwise hold a fetch primitive aimed at your network, including its instance metadata.

So every endpoint must share the issuer's origin — scheme, host and port:

issuer:         https://keycloak.test/realms/main
token_endpoint: https://keycloak.test/realms/main/protocol/openid-connect/token   ✓
token_endpoint: http://169.254.169.254/latest/meta-data/                          ✗
token_endpoint: https://evil.test/token                                           ✗
token_endpoint: https://keycloak.test:8443/token                                  ✗

An origin check rather than an address policy, deliberately. It is stronger — a hostile document cannot name anywhere you did not already pick, including a public host of its own choosing, which an address policy would happily allow. And it is cheaper: no DNS, so it decides the same way in an air-gapped deployment, costs no resolver round trip per login, and is immune to the rebinding race that makes a by-name address check advisory. A self-hosted IdP on 192.168.1.10 needs no exception, because its issuer is that address too — your decision, not the document's.

For the IdPs that genuinely split endpoints across names — Entra ID serves jwks_uri from login.microsoftonline.com while its issuer is sts.windows.net — name the host:

{ driver: "oidc", issuer, allowEndpointHosts: ["login.microsoftonline.com"] }

A named host is trusted as the issuer is, internal addresses included. The scheme and credential rules still apply, so a document answering with javascript: in end_session_endpoint is still refused — that one is never fetched, but logoutUrl() would put it in a Location header.

Checked on every discovery fetch rather than once, so an issuer that starts answering with a hostile document is caught at the next TTL boundary. Raises UnsafeEndpointError, which is distinct from DiscoveryFailedError: the document was obtained and does describe the right issuer, which makes it the shape of an attack rather than a misconfiguration.

Caching and key rotation

Discovery is fetched once and cached, not on every request — the Kovah/laravel-socialite-oidc package refetches on every redirect and every callback, which makes the IdP a hard latency dependency of each login.

JWKS is cached too, but an unknown kid triggers a refetch, rate limited by jwksCooldownSeconds. That is what makes key rotation self-healing within seconds. A flat TTL alone — that package's one-hour Cache::remember — turns every rotation into an hour-long outage. The cooldown is the rate limit that stops an attacker turning bogus kids into amplification against your IdP.

Both caches are shared across fluent copies, so driver.scopes([...]) does not start a cold cache.

Logout

const url = await driver.logoutUrl({ idToken, redirectTo: "https://app.test/goodbye" });

RP-initiated logout, or null when the issuer advertises no end_session_endpoint. Without it, "log out" clears your session while the IdP's SSO session persists and the next login silently re-authenticates.

Claim mapping

sub                             → id
preferred_username ?? nickname  → nickname
name                            → name
email                           → email
picture                         → avatar

preferred_username first because Keycloak, Authentik and Entra ID all send it and rarely send nickname.

Key accounts on (issuer, sub). sub is the only claim guaranteed stable and unique; email and preferred_username are mutable and reassignable, and keying on either is a known account-takeover vector.

OIDC errors

ErrorWhen
DiscoveryFailedErrorThe metadata was unreachable, malformed, or declared a different issuer.
UnsafeEndpointErrorThe metadata named an endpoint off the issuer's origin.
IdTokenInvalidErrorThe token failed validation. Carries reason.
SubjectMismatchErroruserinfo describes a different subject.
EndpointUnsupportedErrorThe issuer advertises no endpoint the driver needs.

IdTokenInvalidError.reason is one of missing, malformed, signature, algorithm, issuer, audience, expired, nonce, claims — all hard rejections, distinguished because nonce and audience failures mean somebody is replaying or cross-submitting tokens while expired is usually a clock.

Errors

Every error extends SocialiteError, so an app that would rather render a generic "sign-in failed" can catch one thing.

ErrorWhen
InvalidStateErrorThe callback's state was not accepted. Carries reason.
MissingAuthorizationCodeErrorNo code. Carries the provider's error, so access_denied is distinguishable.
TokenExchangeFailedErrorThe token endpoint refused. Carries error/error_description.
UserFetchFailedErrorThe user endpoint failed.
MissingDriverConfigErrorA provider is missing required keys. Thrown at boot.
NoDefaultSocialiteDriverErrordriver() was called with no name.

These are not HttpError subclasses, unlike impersonation's. An InvalidStateError is a 403 if somebody forged the callback and a "please try again" if the user sat on the consent screen past ten minutes, and the package cannot tell which — so the app chooses the status.

InvalidStateError.reason distinguishes four cases, because they mean different things operationally:

reasonMeaning
missingNo cookie. Usually benign: expired, or blocked.
unsignedSignature failed. Tampered, or an app key rotated out.
wrong-providerA cookie minted for a different driver.
mismatchVerified, but not the state that was issued. The CSRF case.

A spike in mismatch is worth alerting on; a steady trickle of missing is users with stale tabs.

Writing a driver

Extend Oauth2SocialiteDriver and supply five members:

export class AcmeSocialiteDriver extends Oauth2SocialiteDriver<AcmeRawUser> {
  static readonly meta = { name: "Acme", website: "https://acme.test" };

  protected override readonly scopeSeparator = " ";
  protected override readonly defaultScopes = ["profile", "email"];

  protected meta() { return AcmeSocialiteDriver.meta; }
  protected authUrl() { return "https://acme.test/oauth/authorize"; }
  protected tokenUrl() { return "https://acme.test/oauth/token"; }

  protected withOptions(options: Oauth2DriverOptions) {
    return new AcmeSocialiteDriver(this.context, options);
  }

  protected async fetchUser(token: string): Promise<AcmeRawUser> {
    const response = await this.authenticated(token).get("https://acme.test/userinfo");
    this.assertUserFetched(response);

    return response.json<AcmeRawUser>();
  }

  protected mapUser(raw: AcmeRawUser): MappedSocialiteUser {
    return {
      id: String(raw.sub),
      nickname: raw.preferred_username ?? null,
      name: raw.name ?? null,
      email: raw.email ?? null,
      avatar: raw.picture ?? null,
    };
  }
}

withOptions() is abstract so a subclass cannot forget to be immutable — every fluent method routes through it.

Register it from your own service provider's boot(), which makes the registration order-independent:

export class AcmeSocialiteServiceProvider extends ServiceProvider {
  boot(): void {
    Socialite.extend("acme", () => new AcmeSocialiteDriver(context, defaultDriverOptions()));
  }
}

A provider config naming a driver this package does not ship is skipped, not rejected, so your extend("acme", ...) can claim it — the same arrangement storage has with its disk drivers.

Run the contract

Satisfying the TypeScript interface is not the same as behaving correctly: types cannot express "does not mutate itself" or "rejects a forged state". The contract can.

import { socialiteDriverContract } from "@mahiframework/socialite";

describe("AcmeSocialiteDriver", () => {
  for (const contractCase of socialiteDriverContract(harness)) {
    it(contractCase.name, () => contractCase.run());
  }
});

Fifteen cases, covering the state round trip, single-use cookies, PKCE, and copy-on-write for every fluent method. The package's own test suite checks the contract against deliberately broken drivers first — a contract that cannot fail is decoration.

Testing

Socialite is a facade, so swap() replaces it wholesale, and fakeSocialiteUser() builds a user without an OAuth round trip:

import { Socialite, fakeSocialiteUser } from "@mahiframework/socialite";

Socialite.swap({
  driver: () => ({ user: async () => fakeSocialiteUser({ email: "ada@example.test" }) }),
});

// ...

Socialite.restore();

To exercise the real driver instead, fake the provider's HTTP endpoints with Http.fake(). That is how this package's own suite runs: 147 tests, zero network, no credentials.