Skip to main content

OAuth

WIP

Sign-in through Google, GitHub, Microsoft, Apple, LinkedIn and Discord, the two routes it needs, creating and linking accounts, and adding any other OAuth 2.0 IdP

Each shipped provider is a preset over one OAuth 2.0 implementation: PKCE, a signed state bound to the browser that began the flow by a pre-auth cookie, and a callback that works once.

ProviderImportDefault scopesEmail counted as verified
Googleproviders/oauth/googleopenid email profileWhen Google says so.
GitHubproviders/oauth/githubread:user user:emailThe primary verified address only.
Microsoftproviders/oauth/microsoftopenid profile email User.ReadNever.
Appleproviders/oauth/applename emailWhen the id_token says so.
LinkedInproviders/oauth/linkedinopenid profile emailWhen LinkedIn says so.
Discordproviders/oauth/discordidentify emailWhen Discord says so.

Setup

import { redisDPoPNonceStore } from '@gentleduck/auth/adapters/redis'
import { google } from '@gentleduck/auth/providers/oauth/google'

const auth = createAuth({
  baseUrl: 'https://app.example.com',
  // ...
  providers: [
    google({
      clientId: process.env.GOOGLE_CLIENT_ID ?? '',
      clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '',
      redirectUri: 'https://app.example.com/auth/providers/oauth:google/callback',
      stateSigningSecret: process.env.OAUTH_STATE_SECRET ?? '',
      nonceStore: redisDPoPNonceStore({ redis, prefix: 'auth:oauth:nonce' }),
      profileToIdentityProfile: (p) => (p.email ? { email: p.email, username: `google:${p.sub}`, name: p.name } : null),
    }),
  ],
})

Every provider takes these, plus its own extras:

OptionNotes
clientId, clientSecretFrom the IdP's console. Apple takes a key instead of a secret.
redirectUriMust match what the IdP has registered, exactly.
stateSigningSecretSigns the state. Empty throws; strict() refuses one under 32 bytes in production.
nonceStoreBurns each state on use, so a callback URL recovered from history or a log cannot sign in again. Required unless you pass allowStateReplay: true, which production refuses. memoryDPoPNonceStore() works for one process.
profileToIdentityProfileBuilds the profile for a first-time user. Without it, or when it answers null, a first sign-in is refused.
onFederationConflictSee Linking. Default 'reject'.
onSignInReplaces the lookup below entirely.
stateCookieThe pre-auth cookie, __Host-duck-oauth by default. On plain-http development, pass { name: 'duck-oauth', secure: false }.
scopesReplaces the default scopes.

The routes

A flow is two requests: begin, which sets the pre-auth cookie and redirects to the IdP, and the callback the IdP sends the browser back to. Both are your routes. With Express:

import { AuthError } from '@gentleduck/auth/core'
import { applyIntents, expressCaller, expressCsrf, toHeaders } from '@gentleduck/auth/server/express'
import { isSafeRedirectUrl } from '@gentleduck/auth/server/generic'
import express from 'express'

// Never CSRF-guarded: Apple posts it cross-site, and the signed state is the proof
const callback: express.RequestHandler<{ id: string }> = async (req, res) => {
  const failed = (code: string) => applyIntents([{ type: 'redirect', url: `/sign-in?error=${code}` }], res)
  const id = req.params.id
  if (!auth.providers.has(id) || auth.providers.get(id).kind !== 'oauth') return failed('AUTH_PROVIDER_FAILED')
  const params =
    req.method === 'POST'
      ? new URLSearchParams(typeof req.body === 'string' ? req.body : '')
      : new URL(req.originalUrl, 'http://localhost').searchParams
  const headers = toHeaders(req.headers)
  const user = params.get('user')
  try {
    const { intents } = await auth.flows.signIn({
      input: {
        code: params.get('code') ?? '',
        cookieHeader: headers.get('cookie') ?? '',
        state: params.get('state') ?? '',
        ...(user !== null && { user }),
      },
      providerId: id,
      ...expressCaller(req),
      previousSid: auth.transport.extract({ headers }) ?? undefined,
    })
    const error = intents.find((intent) => intent.type === 'error')
    if (error) return failed(error.code)
    applyIntents([...intents, { type: 'redirect', url: '/' }], res)
  } catch (err) {
    if (!(err instanceof AuthError)) throw err
    failed(err.code)
  }
}
app.get('/auth/providers/:id/callback', callback)
app.post('/auth/providers/:id/callback', express.text({ type: 'application/x-www-form-urlencoded' }), callback)

app.use('/auth', expressCsrf(auth))

// A script cannot follow a redirect to the IdP, so it gets `{ url }` to navigate to
app.post('/auth/providers/:id/begin', express.json(), async (req, res) => {
  const intents = await auth.flows.beginProvider(req.params.id, req.body ?? {})
  applyIntents(
    intents.map((i) =>
      i.type === 'redirect' && isSafeRedirectUrl(i.url) ? { type: 'json', status: 200, body: { url: i.url } } : i,
    ),
    res,
  )
})
  • In the browser, post to begin and location.assign the url it answers.
  • The callback reads the query on a redirect and the form on Apple's POST, which also carries the user field Apple sends only on a first sign-in. It needs the request's cookie header, where the pre-auth cookie is.
  • Where the browser lands is yours: the app on success, and your sign-in page with the code on a refusal.
  • flows.signIn and flows.beginProvider refuse an unknown provider id with AUTH_PROVIDER_FAILED. The kind check keeps the callback to oauth providers.

The other server adapters have the same pieces.

Who signs in

On a callback, the provider finds the identity:

  1. One already linked to this provider account signs in.
  2. Otherwise, one owning the profile's email is a conflict, settled by onFederationConflict.
  3. Otherwise, profileToIdentityProfile creates one, linked to the provider account.

A created identity starts with emailVerified: false: the IdP's claim is not a proof duck-auth made.

Linking to an existing account

An existing account whose email matches is not linked by default. Linking on the IdP's word alone hands an account to anyone who can make an IdP assert its address.

onFederationConflictLinks when
'reject' (default)Never; the sign-in throws AUTH_PROVIDER_FAILED.
'link-if-verified'The IdP marks the address verified and the existing identity proved it too.
(conflict) => 'link' | 'reject'Your function says 'link'; anything else refuses.

A signed-in user can link a provider deliberately with auth.flows.linkProvider.

Any other IdP

oProvider and OAuthClient from @gentleduck/auth/providers/oauth/core are what the six presets are built from:

import { getUserinfoString, OAuthClient, oProvider } from '@gentleduck/auth/providers/oauth/core'

const gitlab = oProvider({
  providerId: 'gitlab',
  client: new OAuthClient({
    clientId: process.env.GITLAB_CLIENT_ID ?? '',
    clientSecret: process.env.GITLAB_CLIENT_SECRET ?? '',
    endpoints: {
      authorizationEndpoint: 'https://gitlab.com/oauth/authorize',
      tokenEndpoint: 'https://gitlab.com/oauth/token',
      userinfoEndpoint: 'https://gitlab.com/oauth/userinfo',
    },
    scopes: ['openid', 'email'],
  }),
  redirectUri: 'https://app.example.com/auth/providers/oauth:gitlab/callback',
  stateSigningSecret: process.env.OAUTH_STATE_SECRET ?? '',
  nonceStore,
  async fetchProfile(tokens, client) {
    const info = await client.userinfo(tokens.access_token)
    const sub = getUserinfoString(info, 'sub')
    if (!sub) throw new Error('gitlab userinfo carried no sub')
    return { sub, email: getUserinfoString(info, 'email') }
  },
})

fetchProfile answers { sub, email?, emailVerified?, name?, avatarUrl? }. Set emailVerified only on a literal true from the IdP, since 'link-if-verified' trusts it.

Errors

CodeCause
AUTH_OAUTH_STATE_MISMATCHThe state does not verify, is over ten minutes old, or reached a browser without the pre-auth cookie.
AUTH_OAUTH_NONCE_REPLAYThe callback was already used.
AUTH_PROVIDER_FAILEDThe IdP refused the code, the profile had no subject, a conflict was refused, or profileToIdentityProfile answered null.

When a sign-in is refused after the code was exchanged, the tokens are revoked at the IdP where it supports revocation.