# 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.



---

<!-- source: en/plugins/auth-social.md -->
## 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

```ts
// 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:

```tsx
<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.

```ts
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.
