4
0

google-standin.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import { randomBytes } from 'node:crypto'
  2. import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
  3. /**
  4. * Google, as far as a sign-in needs it, with nobody's real account in it.
  5. *
  6. * Google sign-in cannot run from a test: it needs a real Google account, a
  7. * consent screen that is Google's to change, and a callback Google would only
  8. * send to an address it knows. So this plays the two parts of Google the app
  9. * touches in a sign-in. The browser is sent to its account chooser (a spec
  10. * routes `accounts.google.com` here), and the app's server exchanges the code
  11. * at its token endpoint (e2e/google-standin-preload.mjs points it here).
  12. *
  13. * The accounts it offers are the ones a spec registers, each with its own
  14. * `email_verified`, because what an account-linking rule does with an address
  15. * Google has not verified is the question most worth a test. The ID token it
  16. * returns is unsigned: in the redirect flow better-auth decodes the token it
  17. * receives straight from the token endpoint and does not check a signature.
  18. */
  19. const PORT = Number(process.env.E2E_GOOGLE_PORT ?? 8027)
  20. interface Account {
  21. sub: string
  22. email: string
  23. email_verified: boolean
  24. name: string
  25. }
  26. interface Grant {
  27. account: Account
  28. clientId: string
  29. redirectUri: string
  30. }
  31. const state = {
  32. accounts: [] as Account[],
  33. /** The query of every trip to the chooser, oldest first. */
  34. authorizeRequests: [] as Record<string, string>[],
  35. /** What the app sent to the token endpoint, oldest first. */
  36. tokenExchanges: [] as { clientId: string; code: string; hadVerifier: boolean }[],
  37. }
  38. const grants = new Map<string, Grant>()
  39. function json(res: ServerResponse, status: number, body: unknown): void {
  40. res.writeHead(status, { 'content-type': 'application/json' })
  41. res.end(JSON.stringify(body))
  42. }
  43. function redirect(res: ServerResponse, to: string): void {
  44. res.writeHead(303, { location: to })
  45. res.end()
  46. }
  47. async function readBody(req: IncomingMessage): Promise<string> {
  48. const chunks: Buffer[] = []
  49. for await (const chunk of req) chunks.push(chunk as Buffer)
  50. return Buffer.concat(chunks).toString('utf8')
  51. }
  52. function escapeHtml(value: string): string {
  53. return value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
  54. }
  55. function base64url(value: string): string {
  56. return Buffer.from(value).toString('base64url')
  57. }
  58. /** An ID token shaped like Google's. Unsigned; see the note at the top. */
  59. function idToken(account: Account, clientId: string): string {
  60. const now = Math.floor(Date.now() / 1000)
  61. const header = base64url(JSON.stringify({ alg: 'RS256', kid: 'e2e-standin', typ: 'JWT' }))
  62. const payload = base64url(
  63. JSON.stringify({
  64. iss: 'https://accounts.google.com',
  65. azp: clientId,
  66. aud: clientId,
  67. sub: account.sub,
  68. email: account.email,
  69. email_verified: account.email_verified,
  70. name: account.name,
  71. iat: now,
  72. exp: now + 3600,
  73. })
  74. )
  75. return `${header}.${payload}.${base64url('e2e-standin-signature')}`
  76. }
  77. function chooser(query: URLSearchParams): string {
  78. const hidden = ['redirect_uri', 'state', 'client_id']
  79. .map((k) => `<input type="hidden" name="${k}" value="${escapeHtml(query.get(k) ?? '')}">`)
  80. .join('')
  81. const accounts = state.accounts
  82. .map(
  83. (a) =>
  84. `<form method="post" action="/o/oauth2/v2/auth/choose">${hidden}` +
  85. `<input type="hidden" name="sub" value="${escapeHtml(a.sub)}">` +
  86. `<button type="submit">${escapeHtml(a.email)}</button></form>`
  87. )
  88. .join('')
  89. const cancel = `${query.get('redirect_uri') ?? ''}?error=access_denied&state=${encodeURIComponent(query.get('state') ?? '')}`
  90. return (
  91. `<!doctype html><html><head><meta charset="utf-8"><title>Sign in - Google Accounts</title></head><body>` +
  92. `<h1>Choose an account</h1>${accounts}<a href="${escapeHtml(cancel)}">Cancel</a></body></html>`
  93. )
  94. }
  95. const server = createServer(async (req, res) => {
  96. const url = new URL(req.url ?? '/', `http://127.0.0.1:${PORT}`)
  97. try {
  98. if (url.pathname === '/health') return json(res, 200, { ok: true })
  99. if (url.pathname === '/state') {
  100. if (req.method === 'DELETE') {
  101. state.accounts.length = 0
  102. state.authorizeRequests.length = 0
  103. state.tokenExchanges.length = 0
  104. grants.clear()
  105. return json(res, 200, { cleared: true })
  106. }
  107. return json(res, 200, state)
  108. }
  109. if (req.method === 'POST' && url.pathname === '/accounts') {
  110. const body = JSON.parse((await readBody(req)) || '{}') as Partial<Account>
  111. if (!body.email) return json(res, 400, { error: 'email is required' })
  112. const account: Account = {
  113. sub: body.sub ?? `e2e-${randomBytes(8).toString('hex')}`,
  114. email: body.email,
  115. email_verified: body.email_verified !== false,
  116. name: body.name ?? body.email,
  117. }
  118. state.accounts.push(account)
  119. return json(res, 200, account)
  120. }
  121. if (req.method === 'GET' && url.pathname === '/o/oauth2/v2/auth') {
  122. state.authorizeRequests.push(Object.fromEntries(url.searchParams))
  123. res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
  124. res.end(chooser(url.searchParams))
  125. return
  126. }
  127. if (req.method === 'POST' && url.pathname === '/o/oauth2/v2/auth/choose') {
  128. const form = new URLSearchParams(await readBody(req))
  129. const account = state.accounts.find((a) => a.sub === form.get('sub'))
  130. const redirectUri = form.get('redirect_uri') ?? ''
  131. if (!account || !redirectUri) return json(res, 400, { error: 'unknown account' })
  132. const code = randomBytes(16).toString('hex')
  133. grants.set(code, { account, clientId: form.get('client_id') ?? '', redirectUri })
  134. const back = new URL(redirectUri)
  135. back.searchParams.set('code', code)
  136. back.searchParams.set('state', form.get('state') ?? '')
  137. back.searchParams.set('scope', 'email profile openid')
  138. return redirect(res, back.toString())
  139. }
  140. if (req.method === 'POST' && url.pathname === '/token') {
  141. const form = new URLSearchParams(await readBody(req))
  142. // The client may authenticate in the body or with Basic, as Google allows.
  143. const basic = (req.headers.authorization ?? '').replace(/^Basic\s+/i, '')
  144. const [basicId] = basic ? Buffer.from(basic, 'base64').toString('utf8').split(':') : []
  145. const clientId = form.get('client_id') ?? decodeURIComponent(basicId ?? '')
  146. const code = form.get('code') ?? ''
  147. state.tokenExchanges.push({ clientId, code, hadVerifier: form.has('code_verifier') })
  148. const grant = grants.get(code)
  149. if (!grant) return json(res, 400, { error: 'invalid_grant' })
  150. // A code is good once, as Google's are.
  151. grants.delete(code)
  152. return json(res, 200, {
  153. access_token: `e2e-google-access-${code}`,
  154. expires_in: 3599,
  155. scope:
  156. 'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile',
  157. token_type: 'Bearer',
  158. id_token: idToken(grant.account, grant.clientId || clientId),
  159. })
  160. }
  161. if (req.method === 'GET' && url.pathname === '/oauth2/v3/certs') {
  162. return json(res, 200, { keys: [] })
  163. }
  164. json(res, 404, { error: `google stand-in has nothing at ${url.pathname}` })
  165. } catch (error) {
  166. json(res, 500, { error: error instanceof Error ? error.message : String(error) })
  167. }
  168. })
  169. server.listen(PORT, '127.0.0.1', () => {
  170. console.log(`[google-standin] account chooser and token endpoint on http://127.0.0.1:${PORT}`)
  171. })