Social login (Google / GitHub / Apple)

First-party Sign in with Google / GitHub / Apple — mandatory PKCE + state, JWKS-verified ID tokens, a deliberate account-linking policy, no identity vendor.

@voltro/plugin-auth-social is "Sign in with Google" without adopting an identity vendor. The six sibling @voltro/plugin-auth-* adapters are enterprise-IdP token verifiers — they check a JWT an IdP already issued. This one runs the whole login: it builds the authorize URL, redeems the authorization code, verifies what came back, decides what the identity means for your users table, and issues the same session cookie password sign-in issues.

Three providers ship: Google, GitHub and Apple.

Install

pnpm add @voltro/plugin-auth-social

Wiring

// app.config.ts
import { authRoutesPlugin, postgresUserStore } from '@voltro/plugin-auth'
import { socialAuthPlugin } from '@voltro/plugin-auth-social'

const auth = {
  defaultTenantId: 'public',
  appBaseUrl: 'https://app.example.com',
}

export default {
  type: 'api' as const,
  name: 'myApi',
  store: 'postgres' as const,
  plugins: [
    authRoutesPlugin({ store: users, ...auth }),
    socialAuthPlugin({
      providers: { google: {}, github: {} },
      users,
      auth,
    }),
  ],
}

An empty {} for a provider means "take the credentials from the environment". Then link to the start route:

<a href="/auth/social/google">Sign in with Google</a>
<a href="/auth/social/github">Sign in with GitHub</a>

Two routes are mounted per provider, under /auth/social by default:

Route What it does
GET /auth/social/<provider> Mints state + a PKCE verifier + an OIDC nonce, stores them in a short-lived HttpOnly cookie, redirects to the provider
GET or POST /auth/social/<provider>/callback Verifies state, redeems the code, verifies the ID token, applies the link policy, issues the session cookie

The session is issued by issueUserSession from @voltro/plugin-auth — the same function password sign-in, magic-link, MFA and passkeys use. So a social login gets the sessions row (device list + server-side revocation), keyed-secret rotation, sliding-window renewal and the membership-carrying Subject automatically.

Credentials

Nothing is ever defaulted. A missing credential fails the boot; it never falls back.

Provider Environment
Google VOLTRO_GOOGLE_CLIENT_ID, VOLTRO_GOOGLE_CLIENT_SECRET
GitHub VOLTRO_GITHUB_CLIENT_ID, VOLTRO_GITHUB_CLIENT_SECRET
Apple VOLTRO_APPLE_CLIENT_ID, VOLTRO_APPLE_TEAM_ID, VOLTRO_APPLE_KEY_ID, VOLTRO_APPLE_PRIVATE_KEY

Register <appBaseUrl>/auth/social/<provider>/callback as the redirect URI with each provider, byte for byte.

Account linking — read this before shipping

The security decision at the heart of social login is one sentence: Google says the person in front of you owns ada@example.com, and your users table already has a row for ada@example.com. Do you log them into it?

Answering "yes, the emails match" is the classic pre-authentication account-takeover vector. A provider that does not verify an address lets an attacker register the victim's email, never confirm it, click "Sign in with X" and land inside the victim's account. So there are two policies, and the default is the strict one:

linkPolicy Behaviour
'never' (default) A social identity never attaches to a pre-existing account. Unknown email ⇒ a new user. Known email ⇒ refused, with a message telling the user to sign in the way they already can and connect the provider from account settings
'verified-email' Links when the provider asserted the address is verified and it is not an Apple private relay. A real, bounded risk, taken deliberately

There is deliberately no policy that links on an unverified email.

The linking that is always sound is not a policy at all: attaching a provider to an account whose session you already hold. Call linkSocialIdentity from an authenticated route — the proof of ownership is the session, which is the only proof that is actually sound. That is the escape hatch every app on 'never' needs.

import { linkSocialIdentity, socialCompleteLogin } from '@voltro/plugin-auth-social'

// inside an authenticated route, after socialCompleteLogin returned a profile
yield* linkSocialIdentity(identities, subject.id, {
  provider: profile.provider,
  providerAccountId: profile.providerAccountId,
  email: profile.email,
  emailVerified: profile.emailVerified,
})

What is verified, and what is not

  • state is mandatory and is never caller-supplied. It is compared in constant time before anything leaves the process — a forged callback never reaches a token endpoint.
  • PKCE (S256) rides on every provider, including GitHub, whose OAuth app flow ignores it. The parameter is unconditional so a future provider cannot silently land on the no-PKCE path.
  • ID tokens are verified against the provider's JWKS — signature (ES256/RS256 only; HMAC algorithms are rejected), iss, aud, exp/iat — through the same verifier every IdP adapter here uses, plus a nonce check that makes an ID token from another login fail.
  • GitHub has nothing signed to verify. Identity comes from GET /user plus GET /user/emails, and only the entry that is both primary and verified is trusted. The self-declared profile email is never used.

Apple: three things that break naive implementations

  1. The name arrives exactly once. Apple returns it in no token — it posts a user form field on the first authorization and never again. The profile flags this as nameIsFirstAuthorizationOnly; persist it then or lose it.
  2. The client secret is a JWT you sign yourself (ES256, from a .p8 key, capped at six months). This plugin does not store one at all: it mints a 15-minute secret per exchange, so there is nothing to rotate and nothing to expire in production half a year later. A TTL above Apple's cap is rejected at call time.
  3. The email may be a per-app private relay (…@privaterelay.appleid.com, flagged by is_private_email). It is verified but it is not the user's address, so linking on it is refused even under 'verified-email'.

One more Apple-specific trap: requesting the name/email scopes makes Apple POST the callback cross-site, and a SameSite=Lax cookie is not sent on a cross-site POST. The plugin writes the login-state cookie SameSite=None; Secure for Apple, which means Apple needs HTTPS even in development.

Options

Option Default Notes
providers Which providers to offer; {} takes credentials from the environment
users The same UserStore authRoutesPlugin runs on
auth The same AuthConfig: session secret, cookie flags, default tenant, subject guards
identities DataStore-backed Where identities are recorded
linkPolicy 'never' See above
prefix /auth/social Route prefix
appBaseUrl auth.appBaseUrl Origin the default redirect URIs are built from
successRedirect auth.successRedirect ?? / Where to send the browser after a login
failureRedirect When set, a refusal redirects with ?social_error=<code> instead of answering JSON
stateTtlSeconds 600 How long a started login may take to come back
stateCookieName voltro:oauth Name of the login-state cookie

Schema

The plugin contributes _voltro_oauth_identities (one row per user per provider account, unique on [provider, providerAccountId]) via extendSchema. It rides the declarative differ — voltro db apply and a voltro dev boot both reconcile it, on every dialect. It is deliberately not swept by retention: these rows are the credential, and a TTL that deleted them would silently un-enrol users.

Post-authentication guards

auth.subjectGuards run here exactly as on every other login path, so an account blocked by @voltro/plugin-deactivation cannot get in through the newest door. Sign-up is exempt — a brand-new user no guard could yet have an opinion about.