OAuth
WIPSign-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.
| Provider | Import | Default scopes | Email counted as verified |
|---|---|---|---|
providers/oauth/google | openid email profile | When Google says so. | |
| GitHub | providers/oauth/github | read:user user:email | The primary verified address only. |
| Microsoft | providers/oauth/microsoft | openid profile email User.Read | Never. |
| Apple | providers/oauth/apple | name email | When the id_token says so. |
providers/oauth/linkedin | openid profile email | When LinkedIn says so. | |
| Discord | providers/oauth/discord | identify email | When 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:
| Option | Notes |
|---|---|
clientId, clientSecret | From the IdP's console. Apple takes a key instead of a secret. |
redirectUri | Must match what the IdP has registered, exactly. |
stateSigningSecret | Signs the state. Empty throws; strict() refuses one under 32 bytes in production. |
nonceStore | Burns 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. |
profileToIdentityProfile | Builds the profile for a first-time user. Without it, or when it answers null, a first sign-in is refused. |
onFederationConflict | See Linking. Default 'reject'. |
onSignIn | Replaces the lookup below entirely. |
stateCookie | The pre-auth cookie, __Host-duck-oauth by default. On plain-http development, pass { name: 'duck-oauth', secure: false }. |
scopes | Replaces 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
beginandlocation.assigntheurlit answers. - The callback reads the query on a redirect and the form on Apple's POST, which also carries the
userfield Apple sends only on a first sign-in. It needs the request'scookieheader, 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.signInandflows.beginProviderrefuse an unknown provider id withAUTH_PROVIDER_FAILED. Thekindcheck keeps the callback tooauthproviders.
The other server adapters have the same pieces.
Who signs in
On a callback, the provider finds the identity:
- One already linked to this provider account signs in.
- Otherwise, one owning the profile's email is a conflict, settled by
onFederationConflict. - Otherwise,
profileToIdentityProfilecreates 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.
onFederationConflict | Links 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
| Code | Cause |
|---|---|
AUTH_OAUTH_STATE_MISMATCH | The state does not verify, is over ten minutes old, or reached a browser without the pre-auth cookie. |
AUTH_OAUTH_NONCE_REPLAY | The callback was already used. |
AUTH_PROVIDER_FAILED | The 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.