Chapter 6: more ways in
WIPAdd passwordless sign-in by mailed link and sign-in with GitHub, write the OAuth callback route, and see when an OAuth account links to an existing one
A password is one way in. This chapter adds two more: a link mailed to the address, and GitHub. Both end in the same kind of session, so nothing downstream changes.
Learning goals
- Register a provider that needs the engine's
deliver. - Redeem a magic link from a page the person clicks through.
- Configure GitHub and write the OAuth callback route.
- Know when an OAuth sign-in links to an existing account.
The OAuth round trip
Step by step
Register both providers
Create an OAuth App on GitHub under Settings > Developer settings > OAuth Apps, with the callback URL http://localhost:3000/auth/providers/oauth:github/callback. Put its id and secret in GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET, and 32 random bytes (openssl rand -base64 32) in OAUTH_STATE_SECRET.
import { MemoryAdapter } from '@gentleduck/auth/adapters/memory'
import { createAuth, orNull } from '@gentleduck/auth/core'
import { cookieTransport, memoryDPoPNonceStore } from '@gentleduck/auth/core/transport'
import { magicLink } from '@gentleduck/auth/providers/magic-link'
import { github } from '@gentleduck/auth/providers/oauth/github'
import { passwords } from '@gentleduck/auth/providers/passwords'
import { deliver } from './mailer'
const stores = new MemoryAdapter()
export const auth = createAuth({
baseUrl: 'http://localhost:3000',
deliver,
stores,
transport: cookieTransport({ name: 'sid', secure: false }),
providers: [
passwords(),
(_engine, send) =>
magicLink({
callbackPath: '/magic-link',
deliver: send,
findIdentityByEmail: (email) => orNull(stores.identities.find({ email })),
}),
github({
clientId: process.env.GITHUB_CLIENT_ID ?? '',
clientSecret: process.env.GITHUB_CLIENT_SECRET ?? '',
redirectUri: 'http://localhost:3000/auth/providers/oauth:github/callback',
stateSigningSecret: process.env.OAUTH_STATE_SECRET ?? '',
stateCookie: { name: 'duck-oauth', secure: false },
nonceStore: memoryDPoPNonceStore(),
profileToIdentityProfile: (p) => (p.email ? { email: p.email, username: `github:${p.sub}` } : null),
onFederationConflict: 'link-if-verified',
}),
],
})
- The magic-link provider is a thunk: the engine calls it with its own
deliver, so both kinds of mail go through one mailer. stateCookiegets a plain name andsecure: falsefor http, as the session cookie did in chapter 1.nonceStoreburns eachstateonce, so a callback URL copied from history cannot sign in again. The memory store serves one process; chapter 8 moves it to Valkey.profileToIdentityProfilebuilds the profile for someone GitHub sends who has no account yet.
The callback route
In src/server.ts, import isSafeRedirectUrl beside errorToHttp. Change the static line to serve .html pages without the extension, and add the two provider routes under the sign-in ones:
app.use(express.static('public', { extensions: ['html'] }))
// A script cannot follow a redirect to the IdP, so it gets `{ url }` to navigate to
app.post('/auth/providers/:id/begin', 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,
)
})
// A GET, so expressCsrf lets it through; the signed state is the proof
app.get('/auth/providers/:id/callback', async (req, res) => {
const failed = (code: string) => res.redirect(`/?error=${code}`)
const id = req.params.id
if (!auth.providers.has(id) || auth.providers.get(id).kind !== 'oauth') return failed('AUTH_PROVIDER_FAILED')
const params = new URL(req.originalUrl, 'http://localhost').searchParams
const headers = toHeaders(req.headers)
try {
const { intents } = await auth.flows.signIn({
input: {
code: params.get('code') ?? '',
cookieHeader: headers.get('cookie') ?? '',
state: params.get('state') ?? '',
},
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)
}
})
The page
Add a form to public/index.html, after the sign-in form:
<form id="magic-link" hidden>
<input name="email" type="email" placeholder="Email me a link" required />
<button>Send link</button>
<button id="github" type="button">Sign in with GitHub</button>
</form>
In src/page.ts, look the two elements up beside the others and add them to the guard:
const linkForm = document.querySelector<HTMLFormElement>('#magic-link')
const github = document.querySelector('#github')
if (!status || !list || !signInForm || !noteForm || !signOut || !linkForm || !github) {
throw new Error('DuckNotes markup is missing')
}
hide the form with the sign-in one inside render:
linkForm.hidden = Boolean(identity)
and wire them at the end:
linkForm.addEventListener('submit', async (event) => {
event.preventDefault()
await post('/auth/providers/magic-link/begin', { email: new FormData(linkForm).get('email') })
status.textContent = 'Check your email'
})
github.addEventListener('click', async () => {
const { url } = await (await post('/auth/providers/oauth:github/begin')).json()
location.assign(url)
})
The page the link opens
<!doctype html>
<meta charset="utf-8" />
<title>DuckNotes</title>
<button>Finish signing in</button>
<script type="module" src="/magic-link.js"></script>
const token = new URLSearchParams(location.search).get('token') ?? ''
document.querySelector('button')?.addEventListener('click', async () => {
const res = await fetch('/auth/signin', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-csrf-token': document.cookie.match(/(?:^|; )duck-csrf=([^;]*)/)?.[1] ?? '',
},
body: JSON.stringify({ providerId: 'magic-link', input: { token } }),
})
if (res.ok) location.assign('/')
else document.body.append((await res.json()).error.code)
})
npx esbuild src/page.ts src/magic-link.ts --bundle --format=esm --outdir=public
npx esbuild src/page.ts src/magic-link.ts --bundle --format=esm --outdir=public
What just happened
- Sending a link answered the same for anyone.
flows.beginProvider('magic-link')answers{ ok: true }whether or not the address has an account, and the server printed a link to/magic-link?token=...only for Ada. - The link signs in on a click, not on load. Mail scanners open links to check them; a page that signed in on load would spend the token before the person got there.
- Redeeming the link proved the address. Ada's identity is now
emailVerified. If it was not before, the link also removed everything else on the account, her password included: until that moment, anyone could have signed up with her address and set it. - GitHub's begin answered a URL, not a redirect. A script cannot follow a redirect to another origin, so the begin route answers
{ url }and the page sends itself there, carrying theduck-oauthcookie that binds the flow to this browser. - The callback found Ada's account. GitHub said her primary address is verified, and so had duck-auth, so
'link-if-verified'linked GitHub to the existing identity. The session's factor isoauth.
Who an OAuth sign-in becomes
| Situation | Result |
|---|---|
| This GitHub account is already linked | That identity signs in. |
| An account owns the email, and both sides verified it | Linked, with 'link-if-verified'. |
| An account owns the email, and either side did not verify it | Refused with AUTH_PROVIDER_FAILED. The default 'reject' refuses every such case. |
| No account owns the email | profileToIdentityProfile creates one, with emailVerified: false. |
Try it
- Sign up a new account with a password and, without verifying it, sign in with GitHub under the same address. The callback redirects to
/?error=AUTH_PROVIDER_FAILED. - Click the same magic link twice. The second click shows
AUTH_RECOVERY_TOKEN_INVALID. - Request two links in a row and use the first. A newer link replaces the older one.
Where we are
ducknotes/
public/
index.html - now with the link form and the GitHub button
magic-link.html - where the link lands
src/
auth.ts - password, magic link, GitHub
server.ts - plus the OAuth callback
page.ts
magic-link.ts
See also
- Magic link: every option, and sign-up by link.
- OAuth: the shared options, linking, and any other IdP.
- GitHub: what the preset reads.
- Chapter 7: a second factor