diff --git a/apps/docu/content/docs/architecture/account-linking.mdx b/apps/docu/content/docs/architecture/account-linking.mdx index b3f36aa1..9aab4b87 100644 --- a/apps/docu/content/docs/architecture/account-linking.mdx +++ b/apps/docu/content/docs/architecture/account-linking.mdx @@ -23,9 +23,12 @@ Logged-in users can link additional OAuth providers from Profile (Settings). The - Uses `oauth_link_state` (distinct from `oauth_state` for sign-in) - Binds the link to `meta.userId` so the exchange attaches to the correct account +- Stores `meta.redirectUri` for Google (from `redirect_uri` query) when multiple callback URLs are configured (web + mobile) - Returns 409 `PROVIDER_ALREADY_LINKED` when the provider is linked to another user - Returns `redirectTo` so the client can return the user to the settings page after success +For Google, when using multiple callback URLs (e.g. web + mobile app), the client passes `redirect_uri` in the link-authorize-url request. It must be in the allowlist (`OAUTH_GOOGLE_CALLBACK_URLS`). See [Authentication](/docs/architecture/authentication) for full Google OAuth setup. + ## Provider trust OAuth providers are trusted for identity on first link. Subsequent links require re-authorization; existing tokens are replaced on each successful link. diff --git a/apps/docu/content/docs/architecture/authentication.mdx b/apps/docu/content/docs/architecture/authentication.mdx index e0db2981..79b55301 100644 --- a/apps/docu/content/docs/architecture/authentication.mdx +++ b/apps/docu/content/docs/architecture/authentication.mdx @@ -123,7 +123,7 @@ OAuth sign-in uses provider authorization + callback flows and issues access + r ### OAuth (Google, Facebook, Twitter) -**Google One Tap:** Uses [Google Identity Services](https://developers.google.com/identity/gsi/web) (GIS). Load GIS script, call `google.accounts.id.prompt()` — popup appears in top-right when user is logged into Google. Client POSTs `{ credential }` to `POST /auth/oauth/google/verify-id-token`. Backend verifies JWT with `google-auth-library`, creates session. **Setup:** `GOOGLE_CLIENT_ID` (Fastify), `NEXT_PUBLIC_GOOGLE_CLIENT_ID` (Next.js). +**Google:** Two flows. **(1) One Tap (primary):** Uses [Google Identity Services](https://developers.google.com/identity/gsi/web) (GIS). Popup in top-right when user is logged into Google. Client POSTs `{ credential }` to `POST /auth/oauth/google/verify-id-token`. **(2) Redirect (fallback + linking):** When One Tap is blocked/dismissed or for account linking. `GET /auth/oauth/google/authorize-url?redirect_uri=` (optional) → callback → `POST /auth/oauth/google/exchange`. Client can send `redirect_uri` to choose which callback URL to use (must be in allowlist). **Setup:** One Tap: `GOOGLE_CLIENT_ID`, `NEXT_PUBLIC_GOOGLE_CLIENT_ID`. Redirect/linking: add `GOOGLE_CLIENT_SECRET`, and either `OAUTH_GOOGLE_CALLBACK_URL` (single) or `OAUTH_GOOGLE_CALLBACK_URLS` (comma-separated for web + mobile). **Facebook:** Redirect flow like GitHub. `GET /auth/oauth/facebook/authorize-url` → `POST /auth/oauth/facebook/exchange`. Callback: `/auth/callback/oauth/facebook`. **Setup:** `FACEBOOK_CLIENT_ID`, `FACEBOOK_CLIENT_SECRET`, `OAUTH_FACEBOOK_CALLBACK_URL`. @@ -135,7 +135,7 @@ When a provider is not configured (503 `OAUTH_NOT_CONFIGURED`), the client shows Logged-in users can link additional OAuth providers from Profile (Settings). Flow: `GET /auth/oauth/{provider}/link-authorize-url` (session/JWT required, no API key) → redirect to provider → callback → exchange with `oauth_link_state` → link account, return `{ token, refreshToken, redirectTo: "/settings?linked=ok" }`. -- **Link-authorize-url:** Session/JWT required, no API key; rate limit 10/hour per user. Stores `oauth_link_state` with `meta.userId`. +- **Link-authorize-url:** Session/JWT required, no API key; rate limit 10/hour per user. Stores `oauth_link_state` with `meta.userId`. Google accepts optional query `redirect_uri` for mobile (must be in `OAUTH_GOOGLE_CALLBACK_URLS`). - **Exchange:** State lookup branches on `oauth_link_state` vs `oauth_state`. Link mode uses `meta.userId`; if provider already linked to another user → 409 `PROVIDER_ALREADY_LINKED`. - **Unlink:** `DELETE /account/link/oauth/:providerId` (Bearer). Guardrail: cannot unlink if it would leave no sign-in method (400 `LAST_SIGN_IN_METHOD`). - **Session user:** `GET /auth/session/user` returns `linkedAccounts: [{ providerId }]`. @@ -306,6 +306,9 @@ All endpoints below are served by `apps/fastify`. The API does not set cookies; - `POST /auth/oauth/github/exchange` → `{ token, refreshToken }` (body: `{ code, state }`) - **OAuth (Google One Tap)** - `POST /auth/oauth/google/verify-id-token` → `{ token, refreshToken }` (body: `{ credential }`) +- **OAuth (Google redirect)** — fallback + linking + - `GET /auth/oauth/google/authorize-url` → `{ redirectUrl }` (optional query: `redirect_uri` — must be in allowlist) + - `POST /auth/oauth/google/exchange` → `{ token, refreshToken, redirectTo? }` (body: `{ code, state }`) - **OAuth (Facebook)** - `GET /auth/oauth/facebook/authorize-url` → `{ redirectUrl }` - `POST /auth/oauth/facebook/exchange` → `{ token, refreshToken }` (body: `{ code, state }`) @@ -324,7 +327,7 @@ All endpoints below are served by `apps/fastify`. The API does not set cookies; - `DELETE /account/link/wallet/:id` → `204` (unlink wallet by id from `linkedWallets`) - `POST /account/link/email/request` → `{ ok }` (body: `email`, `callbackUrl`) - `POST /account/link/email/verify` → `{ token, refreshToken }` (body: `token`) - - `GET /auth/oauth/{github,facebook,twitter}/link-authorize-url` → `{ redirectUrl }` (Session required — cookie/JWT session, no API key; rate limit 10/hour) + - `GET /auth/oauth/{github,google,facebook,twitter}/link-authorize-url` → `{ redirectUrl }` (Session required; Google accepts optional query `redirect_uri` for mobile; rate limit 10/hour) - `DELETE /account/link/oauth/:providerId` → `204` (unlink OAuth provider) **Account link error codes**: `WALLET_ALREADY_LINKED`, `EMAIL_ALREADY_IN_USE`, `PROVIDER_ALREADY_LINKED`, `LAST_SIGN_IN_METHOD` @@ -358,7 +361,7 @@ All endpoints below are served by `apps/fastify`. The API does not set cookies; ## Next.js Auth (web) -**Callback pages** (`/auth/callback/magiclink`, `/auth/callback/change-email`, `/auth/callback/oauth/github`, `/auth/callback/oauth/facebook`, `/auth/callback/oauth/twitter`, `/auth/callback/web3`, `/auth/callback/passkey`) exchange credentials with Fastify, set cookies, and redirect. **Logout** (`/auth/logout`) calls Fastify to revoke the session and clears cookies. +**Callback pages** (`/auth/callback/magiclink`, `/auth/callback/change-email`, `/auth/callback/oauth/github`, `/auth/callback/oauth/google`, `/auth/callback/oauth/facebook`, `/auth/callback/oauth/twitter`, `/auth/callback/web3`, `/auth/callback/passkey`) exchange credentials with Fastify, set cookies, and redirect. **Logout** (`/auth/logout`) calls Fastify to revoke the session and clears cookies. **API routes** (cookie updates only): - `POST /api/auth/update-tokens`: accepts `{ token, refreshToken }`, sets single cookie. Used after 401 refresh (core's `onTokensRefreshed`) and after link email or profile edit. diff --git a/apps/docu/content/docs/deployment/vercel.mdx b/apps/docu/content/docs/deployment/vercel.mdx index e10c95ce..8819a35c 100644 --- a/apps/docu/content/docs/deployment/vercel.mdx +++ b/apps/docu/content/docs/deployment/vercel.mdx @@ -42,6 +42,7 @@ RESEND_API_KEY=re_... PORT=3001 NODE_ENV=production SENTRY_DSN=https://... +# Optional: OAuth — see [Authentication](/docs/architecture/authentication) for GITHUB_*, GOOGLE_*, OAUTH_*_CALLBACK_URL(S). ``` **For Web (`apps/next`):** diff --git a/apps/fastify/.env-sample b/apps/fastify/.env-sample index 0e01ae8a..0ed61bf6 100644 --- a/apps/fastify/.env-sample +++ b/apps/fastify/.env-sample @@ -26,9 +26,13 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres # GITHUB_CLIENT_SECRET= # OAUTH_GITHUB_CALLBACK_URL=http://localhost:3000/auth/callback/oauth/github -# Optional: Google OAuth One Tap (GIS popup, top-right) +# Optional: Google OAuth (One Tap popup + redirect fallback) # GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com -# NEXT_PUBLIC_GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com (same value, for Next.js) +# GOOGLE_CLIENT_SECRET=xxx +# Single callback URL (backward compat): +# OAUTH_GOOGLE_CALLBACK_URL=http://localhost:3000/auth/callback/oauth/google +# Multiple callbacks for web + mobile (comma-separated; client sends redirect_uri to choose): +# OAUTH_GOOGLE_CALLBACK_URLS=http://localhost:3000/auth/callback/oauth/google,yourapp://auth/callback # Optional: Facebook OAuth # FACEBOOK_CLIENT_ID= diff --git a/apps/fastify/eslint.config.mjs b/apps/fastify/eslint.config.mjs index 51ac767a..71c4c567 100644 --- a/apps/fastify/eslint.config.mjs +++ b/apps/fastify/eslint.config.mjs @@ -7,14 +7,8 @@ export default [ 'src/routes/auth/oauth/twitter/exchange.ts', 'src/routes/auth/oauth/github/exchange.ts', 'src/routes/auth/oauth/facebook/exchange.ts', + 'src/routes/auth/oauth/google/exchange.ts', ], rules: { complexity: 'off' }, }, - { - files: ['src/routes/reference/template.ts'], - rules: { - 'max-lines': ['error', { max: 350, skipBlankLines: true, skipComments: true }], - 'max-params': 'off', - }, - }, ] diff --git a/apps/fastify/openapi/openapi.json b/apps/fastify/openapi/openapi.json index a3656be1..6c8a5193 100644 --- a/apps/fastify/openapi/openapi.json +++ b/apps/fastify/openapi/openapi.json @@ -2709,6 +2709,7 @@ "required": [ "github", "google", + "googleRedirect", "facebook", "twitter" ], @@ -2719,6 +2720,9 @@ "google": { "type": "boolean" }, + "googleRedirect": { + "type": "boolean" + }, "facebook": { "type": "boolean" }, @@ -3449,6 +3453,438 @@ } } }, + "/auth/oauth/google/authorize-url": { + "get": { + "operationId": "oauthGoogleAuthorizeUrl", + "summary": "Google OAuth authorize URL", + "tags": [ + "auth" + ], + "description": "Return Google OAuth authorization URL for client-side redirect", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "redirect_uri", + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "redirectUrl" + ], + "properties": { + "redirectUrl": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/auth/oauth/google/exchange": { + "post": { + "operationId": "oauthGoogleExchange", + "summary": "Google OAuth exchange", + "tags": [ + "auth" + ], + "description": "Exchange Google OAuth code for JWTs", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "state" + ], + "properties": { + "code": { + "type": "string" + }, + "state": { + "type": "string" + } + } + } + } + } + }, + "security": [], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "token", + "refreshToken" + ], + "properties": { + "token": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "redirectTo": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "429": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "504": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/auth/oauth/google/link-authorize-url": { + "get": { + "operationId": "oauthGoogleLinkAuthorizeUrl", + "summary": "Google OAuth link authorize URL", + "tags": [ + "auth" + ], + "description": "Return Google OAuth URL for linking account (Bearer required)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "redirect_uri", + "required": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "redirectUrl" + ], + "properties": { + "redirectUrl": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "429": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "503": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, "/auth/oauth/google/verify-id-token": { "post": { "operationId": "oauthGoogleVerifyIdToken", diff --git a/apps/fastify/src/db/schema/tables/verification.ts b/apps/fastify/src/db/schema/tables/verification.ts index 6ef5fbc9..088bf20d 100644 --- a/apps/fastify/src/db/schema/tables/verification.ts +++ b/apps/fastify/src/db/schema/tables/verification.ts @@ -18,7 +18,11 @@ export const verification = pgTable( value: text('value').notNull(), // Token hash or nonce tokenPlain: text('token_plain'), // Plain token for @test.ai when ALLOW_TEST (DB-backed, no fake outbox) expiresAt: timestamp('expires_at').notNull(), - meta: jsonb('meta').$type<{ codeVerifier?: string; userId?: string }>(), + meta: jsonb('meta').$type<{ + codeVerifier?: string + userId?: string + redirectUri?: string + }>(), consumedAt: timestamp('consumed_at'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), diff --git a/apps/fastify/src/lib/env.ts b/apps/fastify/src/lib/env.ts index 9e9fa89b..fa223dc5 100644 --- a/apps/fastify/src/lib/env.ts +++ b/apps/fastify/src/lib/env.ts @@ -89,8 +89,21 @@ export const env = createEnv({ GITHUB_CLIENT_ID: z.string().min(1).optional(), GITHUB_CLIENT_SECRET: z.string().min(1).optional(), OAUTH_GITHUB_CALLBACK_URL: z.string().url().optional(), - // Google OAuth One Tap (optional) + // Google OAuth (optional - One Tap + redirect fallback) GOOGLE_CLIENT_ID: z.string().min(1).optional(), + GOOGLE_CLIENT_SECRET: z.string().min(1).optional(), + OAUTH_GOOGLE_CALLBACK_URL: z.string().url().optional(), + OAUTH_GOOGLE_CALLBACK_URLS: z + .string() + .optional() + .transform(val => + val + ? val + .split(',') + .map(s => s.trim()) + .filter(Boolean) + : undefined, + ), // Facebook OAuth (optional) FACEBOOK_CLIENT_ID: z.string().min(1).optional(), FACEBOOK_CLIENT_SECRET: z.string().min(1).optional(), diff --git a/apps/fastify/src/lib/oauth-google.ts b/apps/fastify/src/lib/oauth-google.ts new file mode 100644 index 00000000..09753096 --- /dev/null +++ b/apps/fastify/src/lib/oauth-google.ts @@ -0,0 +1,95 @@ +const fetchTimeoutMs = 15_000 + +export type GoogleTokenResponse = { + /* biome-ignore lint/style/useNamingConvention: OAuth API uses snake_case */ + access_token: string + /* biome-ignore lint/style/useNamingConvention: OAuth API uses snake_case */ + token_type: string + /* biome-ignore lint/style/useNamingConvention: OAuth API uses snake_case */ + expires_in?: number + scope?: string + /* biome-ignore lint/style/useNamingConvention: OAuth API uses snake_case */ + refresh_token?: string + /* biome-ignore lint/style/useNamingConvention: OAuth API uses snake_case */ + id_token?: string + error?: string +} + +export type GoogleUser = { + id: string + email?: string + name?: string + /* biome-ignore lint/style/useNamingConvention: OAuth API uses snake_case */ + verified_email?: boolean +} + +export async function fetchGoogleTokens(input: { + code: string + codeVerifier: string + redirectUri: string + clientId: string + clientSecret: string +}): Promise { + const { code, codeVerifier, redirectUri, clientId, clientSecret } = input + const tokenBody = new URLSearchParams({ + code, + // biome-ignore lint/style/useNamingConvention: OAuth spec uses snake_case + client_id: clientId, + // biome-ignore lint/style/useNamingConvention: OAuth spec uses snake_case + client_secret: clientSecret, + // biome-ignore lint/style/useNamingConvention: OAuth spec uses snake_case + redirect_uri: redirectUri, + // biome-ignore lint/style/useNamingConvention: OAuth spec uses snake_case + grant_type: 'authorization_code', + // biome-ignore lint/style/useNamingConvention: OAuth spec uses snake_case + code_verifier: codeVerifier, + }) + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenBody.toString(), + signal: AbortSignal.timeout(fetchTimeoutMs), + }) + const tokenData = (await tokenRes.json()) as GoogleTokenResponse + if (!tokenRes.ok) { + const err = new Error('Token exchange failed') as Error & { + status: number + tokenData: GoogleTokenResponse + } + err.status = tokenRes.status + err.tokenData = tokenData + throw err + } + if (tokenData.error || !tokenData.access_token) { + const err = new Error('Token exchange failed') as Error & { + status: number + tokenData: GoogleTokenResponse + } + err.status = 400 + err.tokenData = tokenData + throw err + } + return tokenData +} + +export async function fetchGoogleUserInfo(accessToken: string): Promise { + const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + // biome-ignore lint/style/useNamingConvention: HTTP header canonical form + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(fetchTimeoutMs), + }) + const gUser = (await userRes.json()) as GoogleUser + if (!userRes.ok) { + const err = new Error('User info fetch failed') as Error & { status: number; gUser: GoogleUser } + err.status = userRes.status + err.gUser = gUser + throw err + } + if (!gUser?.id) { + const err = new Error('Invalid user response') as Error & { status: number; gUser: GoogleUser } + err.status = 400 + err.gUser = gUser + throw err + } + return gUser +} diff --git a/apps/fastify/src/routes/auth/oauth.spec.ts b/apps/fastify/src/routes/auth/oauth.spec.ts index 56a5e0e7..e6c9a40f 100644 --- a/apps/fastify/src/routes/auth/oauth.spec.ts +++ b/apps/fastify/src/routes/auth/oauth.spec.ts @@ -23,6 +23,8 @@ import './oauth/facebook/exchange.test' import './oauth/github/authorize.test' import './oauth/github/authorize-url.test' import './oauth/github/exchange.test' +import './oauth/google/authorize-url.test' +import './oauth/google/exchange.test' import './oauth/google/verify-id-token.test' import './oauth/twitter/authorize-url.test' import './oauth/twitter/exchange.test' diff --git a/apps/fastify/src/routes/auth/oauth/google/authorize-url.test.ts b/apps/fastify/src/routes/auth/oauth/google/authorize-url.test.ts new file mode 100644 index 00000000..54c5bcf3 --- /dev/null +++ b/apps/fastify/src/routes/auth/oauth/google/authorize-url.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import { fastify } from '../../oauth.spec.js' + +vi.mock('../../../../lib/env.js', async importOriginal => { + const actual = (await importOriginal()) as { env: Record } + return { + env: { + ...actual.env, + GOOGLE_CLIENT_ID: undefined, + GOOGLE_CLIENT_SECRET: undefined, + OAUTH_GOOGLE_CALLBACK_URL: undefined, + OAUTH_GOOGLE_CALLBACK_URLS: undefined, + }, + } +}) + +describe('GET /auth/oauth/google/authorize-url', () => { + it('returns 503 when Google OAuth redirect is not configured', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/auth/oauth/google/authorize-url', + }) + expect(res.statusCode).toBe(503) + const body = res.json() as { code?: string; message?: string } + expect(body.code).toBe('OAUTH_NOT_CONFIGURED') + }) +}) diff --git a/apps/fastify/src/routes/auth/oauth/google/authorize-url.ts b/apps/fastify/src/routes/auth/oauth/google/authorize-url.ts new file mode 100644 index 00000000..8d730724 --- /dev/null +++ b/apps/fastify/src/routes/auth/oauth/google/authorize-url.ts @@ -0,0 +1,101 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' +import { Type } from '@sinclair/typebox' +import type { FastifyPluginAsync } from 'fastify' +import { getDb } from '../../../../db/index.js' +import { verification } from '../../../../db/schema/index.js' +import { env } from '../../../../lib/env.js' +import { hashToken } from '../../../../lib/jwt.js' +import { ErrorResponseSchema } from '../../../schemas.js' + +const AuthorizeUrlResponseSchema = Type.Object({ + redirectUrl: Type.String(), +}) + +const AuthorizeUrlQuerystringSchema = Type.Object({ + redirect_uri: Type.Optional(Type.String()), +}) + +function generateCodeVerifier(): string { + return randomBytes(32).toString('base64url') +} + +function generateCodeChallenge(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url') +} + +const oauthAuthorizeUrlRoute: FastifyPluginAsync = async fastify => { + fastify.withTypeProvider().get( + '/authorize-url', + { + schema: { + operationId: 'oauthGoogleAuthorizeUrl', + description: 'Return Google OAuth authorization URL for client-side redirect', + summary: 'Google OAuth authorize URL', + tags: ['auth'], + security: [], + querystring: AuthorizeUrlQuerystringSchema, + response: { + 200: AuthorizeUrlResponseSchema, + 400: ErrorResponseSchema, + 503: ErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + const googleClientId = env.GOOGLE_CLIENT_ID + const googleClientSecret = env.GOOGLE_CLIENT_SECRET + const allowedUrls = + env.OAUTH_GOOGLE_CALLBACK_URLS ?? + (env.OAUTH_GOOGLE_CALLBACK_URL ? [env.OAUTH_GOOGLE_CALLBACK_URL] : []) + const defaultUrl = allowedUrls[0] + if (!googleClientId || !googleClientSecret || !defaultUrl) + return reply.status(503).send({ + code: 'OAUTH_NOT_CONFIGURED', + message: 'Google OAuth redirect is not configured', + }) + + const requested = (request.query as { redirect_uri?: string })?.redirect_uri + const redirectUri = requested + ? allowedUrls.includes(requested) + ? requested + : null + : defaultUrl + if (!redirectUri) + return reply.status(400).send({ + code: 'INVALID_REDIRECT_URI', + message: 'redirect_uri must be one of the configured callback URLs', + }) + + const state = randomUUID() + randomUUID().replace(/-/g, '') + const stateHash = hashToken(state) + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + const expiresAt = new Date(Date.now() + 10 * 60 * 1000) + + const db = await getDb() + await db.insert(verification).values({ + id: randomUUID(), + type: 'oauth_state', + identifier: stateHash, + value: stateHash, + meta: { redirectUri, codeVerifier }, + expiresAt, + }) + + const redirectUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth') + redirectUrl.searchParams.set('client_id', googleClientId) + redirectUrl.searchParams.set('redirect_uri', redirectUri) + redirectUrl.searchParams.set('response_type', 'code') + redirectUrl.searchParams.set('scope', 'openid email profile') + redirectUrl.searchParams.set('code_challenge', codeChallenge) + redirectUrl.searchParams.set('code_challenge_method', 'S256') + redirectUrl.searchParams.set('state', state) + + return reply.status(200).send({ redirectUrl: redirectUrl.toString() }) + }, + ) +} + +export default oauthAuthorizeUrlRoute +export const prefixOverride = '/auth/oauth/google' diff --git a/apps/fastify/src/routes/auth/oauth/google/exchange-helpers.ts b/apps/fastify/src/routes/auth/oauth/google/exchange-helpers.ts new file mode 100644 index 00000000..4b51b8f3 --- /dev/null +++ b/apps/fastify/src/routes/auth/oauth/google/exchange-helpers.ts @@ -0,0 +1,40 @@ +type OAuthError = { code: string; message: string; status?: number } + +type AllowedStatus = 400 | 401 | 429 | 500 | 503 | 504 + +/** Map upstream HTTP status to allowed OAuth exchange response codes. */ +export function toAllowedStatus(raw: number, fallback: AllowedStatus = 400): AllowedStatus { + if (raw === 401 || raw === 429) return raw + if (raw >= 500) return raw === 504 ? 504 : raw === 503 ? 503 : 500 + return fallback +} + +/** Extract token exchange error for Google OAuth; returns null if not a known error. */ +export function buildTokenExchangeError(err: unknown): OAuthError | null { + if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) + return { code: 'TOKEN_EXCHANGE_FAILED', message: 'Token exchange timed out' } + if (err && typeof err === 'object' && 'tokenData' in err) { + const e = err as { tokenData: { error?: string }; status?: number } + return { + code: 'TOKEN_EXCHANGE_FAILED', + message: e.tokenData.error ?? 'Failed to exchange code for token', + status: e.status, + } + } + return null +} + +/** Extract user info fetch error for Google OAuth; returns null if not a known error. */ +export function buildUserInfoError(err: unknown): OAuthError | null { + if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) + return { code: 'USER_INFO_FAILED', message: 'Failed to fetch Google user (timeout)' } + if (err && typeof err === 'object' && 'gUser' in err) { + const e = err as { gUser: unknown; status?: number } + return { + code: 'USER_INFO_FAILED', + message: 'Failed to fetch Google user', + status: e.status, + } + } + return null +} diff --git a/apps/fastify/src/routes/auth/oauth/google/exchange.test.ts b/apps/fastify/src/routes/auth/oauth/google/exchange.test.ts new file mode 100644 index 00000000..2e792f65 --- /dev/null +++ b/apps/fastify/src/routes/auth/oauth/google/exchange.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest' +import { fastify } from '../../oauth.spec.js' + +vi.mock('../../../../lib/env.js', async importOriginal => { + const actual = (await importOriginal()) as { env: Record } + return { + env: { + ...actual.env, + GOOGLE_CLIENT_ID: undefined, + GOOGLE_CLIENT_SECRET: undefined, + OAUTH_GOOGLE_CALLBACK_URL: undefined, + OAUTH_GOOGLE_CALLBACK_URLS: undefined, + }, + } +}) + +describe('POST /auth/oauth/google/exchange', () => { + it('returns 503 when Google OAuth redirect is not configured', async () => { + const res = await fastify.inject({ + method: 'POST', + url: '/auth/oauth/google/exchange', + payload: { code: 'test', state: 'test' }, + }) + expect(res.statusCode).toBe(503) + const body = res.json() as { code?: string; message?: string } + expect(body.code).toBe('OAUTH_NOT_CONFIGURED') + }) +}) diff --git a/apps/fastify/src/routes/auth/oauth/google/exchange.ts b/apps/fastify/src/routes/auth/oauth/google/exchange.ts new file mode 100644 index 00000000..6d79404c --- /dev/null +++ b/apps/fastify/src/routes/auth/oauth/google/exchange.ts @@ -0,0 +1,276 @@ +import { randomUUID } from 'node:crypto' +import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' +import { Type } from '@sinclair/typebox' +import { and, eq } from 'drizzle-orm' +import type { FastifyPluginAsync } from 'fastify' +import { encryptAccountTokens } from '../../../../db/account.js' +import { getDb } from '../../../../db/index.js' +import { account, sessions, users } from '../../../../db/schema/index.js' +import { env } from '../../../../lib/env.js' +import { + createAccessTokenPayload, + createRefreshTokenPayload, + generateJti, + hashToken, +} from '../../../../lib/jwt.js' +import { validateAndConsumeOAuthState } from '../../../../lib/oauth-exchange-state.js' +import { + fetchGoogleTokens, + fetchGoogleUserInfo, + type GoogleTokenResponse, +} from '../../../../lib/oauth-google.js' +import { findOrCreateUserByEmail } from '../../../../lib/oauth-user.js' +import { ErrorResponseSchema } from '../../../schemas.js' +import { buildTokenExchangeError, buildUserInfoError, toAllowedStatus } from './exchange-helpers.js' + +const ExchangeSchema = Type.Object({ + code: Type.String(), + state: Type.String(), +}) + +const ExchangeResponseSchema = Type.Object({ + token: Type.String(), + refreshToken: Type.String(), + redirectTo: Type.Optional(Type.String()), +}) + +const oauthExchangeRoute: FastifyPluginAsync = async fastify => { + fastify.withTypeProvider().post( + '/exchange', + { + schema: { + operationId: 'oauthGoogleExchange', + description: 'Exchange Google OAuth code for JWTs', + summary: 'Google OAuth exchange', + tags: ['auth'], + security: [], + body: ExchangeSchema, + response: { + 200: ExchangeResponseSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema, + 409: ErrorResponseSchema, + 429: ErrorResponseSchema, + 500: ErrorResponseSchema, + 503: ErrorResponseSchema, + 504: ErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + const googleClientId = env.GOOGLE_CLIENT_ID + const googleClientSecret = env.GOOGLE_CLIENT_SECRET + const allowedUrls = + env.OAUTH_GOOGLE_CALLBACK_URLS ?? + (env.OAUTH_GOOGLE_CALLBACK_URL ? [env.OAUTH_GOOGLE_CALLBACK_URL] : []) + const defaultUrl = allowedUrls[0] + if (!googleClientId || !googleClientSecret || !defaultUrl) + return reply.code(503).send({ + code: 'OAUTH_NOT_CONFIGURED', + message: 'Google OAuth redirect is not configured', + }) + + const { code, state } = request.body + const stateHash = hashToken(state) + + const db = await getDb() + const validated = await validateAndConsumeOAuthState({ + db, + stateHash, + request, + reply, + preConsumeCheck: r => + !r.meta?.codeVerifier + ? { code: 'INVALID_STATE', message: 'Missing code verifier for Google PKCE' } + : null, + }) + if (!validated.ok) return + const { isLinkMode, linkUserId, stateRecord } = validated + if (isLinkMode && !linkUserId) + return reply.code(401).send({ + code: 'INVALID_STATE', + message: 'Link mode requires user ID', + }) + const redirectUri = stateRecord.meta?.redirectUri ?? defaultUrl + if (!allowedUrls.includes(redirectUri)) + return reply.code(401).send({ + code: 'INVALID_STATE', + message: 'Invalid or tampered redirect URI', + }) + const codeVerifier = stateRecord.meta?.codeVerifier + if (!codeVerifier) + return reply.code(401).send({ + code: 'INVALID_STATE', + message: 'Missing code verifier for Google PKCE', + }) + + let tokenData: GoogleTokenResponse + try { + tokenData = await fetchGoogleTokens({ + code, + codeVerifier, + redirectUri, + clientId: googleClientId, + clientSecret: googleClientSecret, + }) + } catch (err) { + const tokenErr = buildTokenExchangeError(err) + if (tokenErr) { + const raw = tokenErr.status ?? (tokenErr.message.includes('timeout') ? 504 : 400) + return reply + .code(toAllowedStatus(raw)) + .send({ code: tokenErr.code, message: tokenErr.message }) + } + throw err + } + + let gUser: { id: string; email?: string; name?: string; verified_email?: boolean } + try { + gUser = await fetchGoogleUserInfo(tokenData.access_token) + } catch (err) { + const userErr = buildUserInfoError(err) + if (userErr) { + const raw = userErr.status ?? (userErr.message.includes('timeout') ? 504 : 400) + return reply + .code(toAllowedStatus(raw)) + .send({ code: userErr.code, message: userErr.message }) + } + throw err + } + const accountId = gUser.id + const email = gUser.email ?? '' + const name = gUser.name ?? 'Google user' + const verifiedEmail = gUser.verified_email ?? false + + if (!email || !verifiedEmail) + return reply.code(400).send({ + code: 'EMAIL_REQUIRED', + message: 'Could not retrieve verified email from Google', + }) + + const [existingAccount] = await db + .select() + .from(account) + .where(and(eq(account.providerId, 'google'), eq(account.accountId, accountId))) + + if (isLinkMode) + if (existingAccount && existingAccount.userId !== linkUserId) + return reply.code(409).send({ + code: 'PROVIDER_ALREADY_LINKED', + message: 'This Google account is already linked to another user', + }) + + let user: { id: string } + if (isLinkMode && linkUserId) { + const [u] = await db.select().from(users).where(eq(users.id, linkUserId)) + if (!u) + return reply.code(401).send({ + code: 'INVALID_STATE', + message: 'User not found for link', + }) + user = u + } else if (existingAccount) { + const [u] = await db.select().from(users).where(eq(users.id, existingAccount.userId)) + if (!u) + return reply.code(500).send({ + code: 'USER_NOT_FOUND', + message: 'Account references missing user', + }) + user = u + } else { + const u = await findOrCreateUserByEmail(db, { email, name, emailVerified: true }) + if (!u) + return reply.code(500).send({ + code: 'USER_CREATE_FAILED', + message: 'Failed to create or find user', + }) + user = u + } + + const accountData = { + id: existingAccount?.id ?? randomUUID(), + userId: user.id, + accountId, + providerId: 'google', + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token ?? null, + idToken: tokenData.id_token ?? null, + accessTokenExpiresAt: new Date(Date.now() + (tokenData.expires_in ?? 3600) * 1000), + refreshTokenExpiresAt: null as Date | null, + scope: tokenData.scope ?? 'openid email profile', + } + + if (existingAccount) { + const encrypted = encryptAccountTokens({ + accessToken: accountData.accessToken, + refreshToken: accountData.refreshToken, + idToken: accountData.idToken, + updatedAt: new Date(), + }) + await db + .update(account) + .set({ + accessToken: encrypted.accessToken, + refreshToken: tokenData.refresh_token + ? (encrypted.refreshToken ?? null) + : existingAccount.refreshToken, + idToken: encrypted.idToken ?? null, + updatedAt: encrypted.updatedAt ?? new Date(), + accessTokenExpiresAt: accountData.accessTokenExpiresAt, + scope: accountData.scope, + }) + .where(eq(account.id, existingAccount.id)) + } else { + const toInsert = encryptAccountTokens({ + id: accountData.id, + userId: accountData.userId, + accountId: accountData.accountId, + providerId: accountData.providerId, + accessToken: accountData.accessToken, + refreshToken: accountData.refreshToken, + idToken: accountData.idToken, + accessTokenExpiresAt: accountData.accessTokenExpiresAt, + refreshTokenExpiresAt: accountData.refreshTokenExpiresAt, + scope: accountData.scope, + }) + await db.insert(account).values(toInsert) + } + + const sessionId = randomUUID() + const refreshJti = generateJti() + const refreshJtiHash = hashToken(refreshJti) + const sessionExpiresAt = new Date(Date.now() + env.REFRESH_JWT_EXPIRES_IN_SECONDS * 1000) + + await db.insert(sessions).values({ + id: sessionId, + userId: user.id, + token: refreshJtiHash, + expiresAt: sessionExpiresAt, + }) + + const accessPayload = createAccessTokenPayload({ userId: user.id, sessionId }) + const refreshPayload = createRefreshTokenPayload({ + userId: user.id, + sessionId, + jti: refreshJti, + }) + + const jwtAccess = fastify.jwt.sign(accessPayload, { + expiresIn: `${env.ACCESS_JWT_EXPIRES_IN_SECONDS}s`, + }) + const jwtRefresh = fastify.jwt.sign(refreshPayload, { + expiresIn: `${env.REFRESH_JWT_EXPIRES_IN_SECONDS}s`, + }) + + const payload: { token: string; refreshToken: string; redirectTo?: string } = { + token: jwtAccess, + refreshToken: jwtRefresh, + } + if (isLinkMode) payload.redirectTo = '/settings?linked=ok' + return reply.code(200).send(payload) + }, + ) +} + +export default oauthExchangeRoute +export const prefixOverride = '/auth/oauth/google' diff --git a/apps/fastify/src/routes/auth/oauth/google/link-authorize-url.ts b/apps/fastify/src/routes/auth/oauth/google/link-authorize-url.ts new file mode 100644 index 00000000..509f1702 --- /dev/null +++ b/apps/fastify/src/routes/auth/oauth/google/link-authorize-url.ts @@ -0,0 +1,130 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' +import { Type } from '@sinclair/typebox' +import { and, eq, gte, sql } from 'drizzle-orm' +import type { FastifyPluginAsync } from 'fastify' +import { getDb } from '../../../../db/index.js' +import { verification } from '../../../../db/schema/index.js' +import { env } from '../../../../lib/env.js' +import { hashToken } from '../../../../lib/jwt.js' +import { ErrorResponseSchema } from '../../../schemas.js' + +const linkAuthorizeUrlPerUserPerHour = 10 + +const AuthorizeUrlResponseSchema = Type.Object({ + redirectUrl: Type.String(), +}) + +const LinkAuthorizeUrlQuerystringSchema = Type.Object({ + redirect_uri: Type.Optional(Type.String()), +}) + +function generateCodeVerifier(): string { + return randomBytes(32).toString('base64url') +} + +function generateCodeChallenge(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url') +} + +const oauthLinkAuthorizeUrlRoute: FastifyPluginAsync = async fastify => { + fastify.withTypeProvider().get( + '/link-authorize-url', + { + schema: { + operationId: 'oauthGoogleLinkAuthorizeUrl', + description: 'Return Google OAuth URL for linking account (Bearer required)', + summary: 'Google OAuth link authorize URL', + tags: ['auth'], + security: [{ bearerAuth: [] }], + querystring: LinkAuthorizeUrlQuerystringSchema, + response: { + 200: AuthorizeUrlResponseSchema, + 401: ErrorResponseSchema, + 400: ErrorResponseSchema, + 429: ErrorResponseSchema, + 503: ErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + if (!request.session) + return reply.code(401).send({ + code: 'UNAUTHORIZED', + message: 'Authentication required', + }) + + const googleClientId = env.GOOGLE_CLIENT_ID + const googleClientSecret = env.GOOGLE_CLIENT_SECRET + const allowedUrls = + env.OAUTH_GOOGLE_CALLBACK_URLS ?? + (env.OAUTH_GOOGLE_CALLBACK_URL ? [env.OAUTH_GOOGLE_CALLBACK_URL] : []) + const defaultUrl = allowedUrls[0] + if (!googleClientId || !googleClientSecret || !defaultUrl) + return reply.status(503).send({ + code: 'OAUTH_NOT_CONFIGURED', + message: 'Google OAuth redirect is not configured', + }) + + const requested = (request.query as { redirect_uri?: string })?.redirect_uri + const redirectUri = requested + ? allowedUrls.includes(requested) + ? requested + : null + : defaultUrl + if (!redirectUri) + return reply.status(400).send({ + code: 'INVALID_REDIRECT_URI', + message: 'redirect_uri must be one of the configured callback URLs', + }) + + const userId = request.session.user.id + const db = await getDb() + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000) + const [recentCount] = await db + .select({ count: sql`count(*)::int` }) + .from(verification) + .where( + and( + eq(verification.type, 'oauth_link_state'), + eq(verification.identifier, `link:${userId}`), + gte(verification.createdAt, oneHourAgo), + ), + ) + if ((recentCount?.count ?? 0) >= linkAuthorizeUrlPerUserPerHour) + return reply.code(429).send({ + code: 'TOO_MANY_REQUESTS', + message: 'Too many link requests. Try again later.', + }) + + const state = randomUUID() + randomUUID().replace(/-/g, '') + const stateHash = hashToken(state) + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + const expiresAt = new Date(Date.now() + 10 * 60 * 1000) + + await db.insert(verification).values({ + id: randomUUID(), + type: 'oauth_link_state', + identifier: `link:${userId}`, + value: stateHash, + meta: { userId, redirectUri, codeVerifier }, + expiresAt, + }) + + const redirectUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth') + redirectUrl.searchParams.set('client_id', googleClientId) + redirectUrl.searchParams.set('redirect_uri', redirectUri) + redirectUrl.searchParams.set('response_type', 'code') + redirectUrl.searchParams.set('scope', 'openid email profile') + redirectUrl.searchParams.set('code_challenge', codeChallenge) + redirectUrl.searchParams.set('code_challenge_method', 'S256') + redirectUrl.searchParams.set('state', state) + + return reply.status(200).send({ redirectUrl: redirectUrl.toString() }) + }, + ) +} + +export default oauthLinkAuthorizeUrlRoute +export const prefixOverride = '/auth/oauth/google' diff --git a/apps/fastify/src/routes/auth/oauth/providers.test.ts b/apps/fastify/src/routes/auth/oauth/providers.test.ts index 478b98e3..bf1c4fce 100644 --- a/apps/fastify/src/routes/auth/oauth/providers.test.ts +++ b/apps/fastify/src/routes/auth/oauth/providers.test.ts @@ -11,11 +11,13 @@ describe('GET /auth/oauth/providers', () => { const body = res.json() as { github: boolean google: boolean + googleRedirect: boolean facebook: boolean twitter: boolean } expect(typeof body.github).toBe('boolean') expect(typeof body.google).toBe('boolean') + expect(typeof body.googleRedirect).toBe('boolean') expect(typeof body.facebook).toBe('boolean') expect(typeof body.twitter).toBe('boolean') }) diff --git a/apps/fastify/src/routes/auth/oauth/providers.ts b/apps/fastify/src/routes/auth/oauth/providers.ts index e2491e50..e08371aa 100644 --- a/apps/fastify/src/routes/auth/oauth/providers.ts +++ b/apps/fastify/src/routes/auth/oauth/providers.ts @@ -6,6 +6,7 @@ import { env } from '../../../lib/env.js' const ProvidersResponseSchema = Type.Object({ github: Type.Boolean(), google: Type.Boolean(), + googleRedirect: Type.Boolean(), facebook: Type.Boolean(), twitter: Type.Boolean(), }) @@ -32,6 +33,14 @@ const oauthProvidersRoute: FastifyPluginAsync = async fastify => { Boolean(env.GITHUB_CLIENT_SECRET) && Boolean(env.OAUTH_GITHUB_CALLBACK_URL), google: Boolean(env.GOOGLE_CLIENT_ID), + googleRedirect: (() => { + const urls = + env.OAUTH_GOOGLE_CALLBACK_URLS ?? + (env.OAUTH_GOOGLE_CALLBACK_URL ? [env.OAUTH_GOOGLE_CALLBACK_URL] : []) + return ( + Boolean(env.GOOGLE_CLIENT_ID) && Boolean(env.GOOGLE_CLIENT_SECRET) && urls.length > 0 + ) + })(), facebook: Boolean(env.FACEBOOK_CLIENT_ID) && Boolean(env.FACEBOOK_CLIENT_SECRET) && diff --git a/apps/fastify/src/routes/reference/template-scripts.ts b/apps/fastify/src/routes/reference/template-scripts.ts new file mode 100644 index 00000000..4404058c --- /dev/null +++ b/apps/fastify/src/routes/reference/template-scripts.ts @@ -0,0 +1,247 @@ +function getButtonInjectionScript(): string { + return ` + function findConfigureButton(container) { + let btn = Array.from(container.querySelectorAll('button')).find(b => b.textContent?.trim() === 'Configure'); + if (!btn) btn = Array.from(container.querySelectorAll('button')).find(b => b.textContent?.toLowerCase().trim() === 'configure'); + if (!btn) { + const buttons = Array.from(container.querySelectorAll('button')); + if (buttons.length === 0) return null; + btn = buttons[buttons.length - 1]; + } + return btn; + } + + function createLoginButton(configureButton, scalarApiReference, showModal) { + const link = document.createElement('button'); + const currentToken = localStorage.getItem('scalar-token'); + link.textContent = currentToken ? 'Logout' : 'Login'; + link.setAttribute('data-login-link', 'true'); + link.setAttribute('type', 'button'); + const style = window.getComputedStyle(configureButton); + const props = ['background', 'border', 'color', 'cursor', 'fontSize', 'fontFamily', 'fontWeight', 'fontStyle', 'letterSpacing', 'lineHeight', 'padding', 'margin', 'borderRadius', 'transition', 'display', 'alignItems', 'gap', 'textDecoration', 'textTransform']; + link.style.cssText = props.map(p => p + ': ' + style[p]).join('; ') + ';'; + if (configureButton.className) link.className = configureButton.className; + link.onmouseenter = () => { + const hover = window.getComputedStyle(configureButton, ':hover'); + if (hover.backgroundColor) link.style.backgroundColor = hover.backgroundColor; + }; + link.onmouseleave = () => link.style.backgroundColor = style.backgroundColor; + link.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + const token = localStorage.getItem('scalar-token'); + if (token) { + localStorage.removeItem('scalar-token'); + updateScalarAuth(scalarApiReference, ''); + location.reload(); + } else { + showModal(); + } + }; + configureButton.insertAdjacentElement('afterend', link); + return true; + } + + function injectLoginLinkInternal(scalarApiReference, showModal) { + let injected = false; + let attempts = 0; + const maxAttempts = 150; + const tryInject = () => { + attempts++; + if (document.querySelector('[data-login-link]')) return true; + const devBtn = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === 'Developer Tools'); + if (devBtn) { + let container = devBtn.parentElement; + while (container && container !== document.body) { + const buttons = Array.from(container.querySelectorAll('button')); + if (buttons.length >= 4 && buttons.includes(devBtn)) { + const cfgBtn = findConfigureButton(container); + if (cfgBtn) return createLoginButton(cfgBtn, scalarApiReference, showModal); + } + container = container.parentElement; + } + if (devBtn.parentElement) { + const parentBtns = Array.from(devBtn.parentElement.querySelectorAll('button')); + if (parentBtns.length >= 2 && parentBtns.some(b => b.textContent?.trim() === 'Configure')) { + const cfgBtn = findConfigureButton(devBtn.parentElement); + if (cfgBtn) return createLoginButton(cfgBtn, scalarApiReference, showModal); + } + } + } + return false; + }; + window.updateLoginButton = () => { + const link = document.querySelector('[data-login-link]'); + if (link) link.textContent = localStorage.getItem('scalar-token') ? 'Logout' : 'Login'; + }; + if (tryInject()) return; + const observer = new MutationObserver(() => { + if (!injected && tryInject()) { + injected = true; + observer.disconnect(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + const interval = setInterval(() => { + if (injected || tryInject() || attempts >= maxAttempts) { + clearInterval(interval); + observer.disconnect(); + } + }, 100); + }` +} + +export function getInitScript(opts: { + apiUrl: string + openApiUrl: string + callbackUrl: string + jwtToken: string | null + verificationId?: string +}): string { + const { apiUrl, openApiUrl, callbackUrl, jwtToken, verificationId } = opts + const jwtJson = jwtToken ? JSON.stringify(jwtToken) : 'null' + const verificationIdJson = verificationId ? JSON.stringify(verificationId) : 'null' + const buttonScript = getButtonInjectionScript() + + return ` +(function() { + const apiUrl = ${JSON.stringify(apiUrl)}; + const callbackUrl = ${JSON.stringify(callbackUrl)}; + const openApiUrl = ${JSON.stringify(openApiUrl)}; + const jwtFromServer = ${jwtJson}; + const verificationIdFromUrl = ${verificationIdJson}; + + function updateScalarAuth(scalarApiReference, token) { + const authConfig = { + preferredSecurityScheme: 'bearerAuth', + securitySchemes: { bearerAuth: { token } }, + }; + if (scalarApiReference?.updateConfiguration) { + scalarApiReference.updateConfiguration({ authentication: authConfig }); + } else if (scalarApiReference?.updateAuthentication) { + scalarApiReference.updateAuthentication(authConfig); + } + } + + const storedToken = localStorage.getItem('scalar-token'); + const token = jwtFromServer || storedToken; + + let scalarApiReference = null; + try { + scalarApiReference = Scalar.createApiReference('#scalar-container', { + url: openApiUrl, + theme: 'moon', + authentication: { + preferredSecurityScheme: 'bearerAuth', + securitySchemes: { bearerAuth: { token: token || '' } }, + }, + }); + } catch (error) { + console.error('Failed to initialize Scalar:', error); + } + + if (jwtFromServer) { + localStorage.setItem('scalar-token', jwtFromServer); + history.replaceState({}, '', '/reference'); + updateScalarAuth(scalarApiReference, jwtFromServer); + } else if (verificationIdFromUrl) { + const banner = document.createElement('div'); + banner.id = 'verify-banner'; + banner.style.cssText = 'position:fixed;top:0;left:0;right:0;padding:12px 16px;background:#1e3a5f;color:#fff;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap;z-index:10000;font-size:14px;'; + banner.innerHTML = 'Enter the 6-digit code from your email:
'; + document.body.prepend(banner); + banner.querySelector('form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const input = banner.querySelector('input'); + const code = input?.value?.trim(); + if (!code || code.length !== 6) return; + const errorEl = banner.querySelector('[data-verify-error]'); + if (errorEl) errorEl.textContent = ''; + try { + const res = await fetch(apiUrl + '/auth/magiclink/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ verificationId: verificationIdFromUrl, token: code }), + }); + if (res.ok) { + const data = await res.json(); + localStorage.setItem('scalar-token', data.token); + history.replaceState({}, '', '/reference'); + updateScalarAuth(scalarApiReference, data.token); + banner.remove(); + if (window.updateLoginButton) window.updateLoginButton(); + } else { + let msg = 'Verification failed. Please try again.'; + try { + const body = await res.json(); + msg = body.message || msg; + } catch {} + const errDiv = banner.querySelector('[data-verify-error]'); + if (errDiv) errDiv.textContent = msg; + } + } catch (err) { + const msg = 'Verification failed. Please try again.'; + const errDiv = banner.querySelector('[data-verify-error]'); + if (errDiv) errDiv.textContent = msg; + } + }); + } + + window.scalarApiReference = scalarApiReference; + + const modalOverlay = document.getElementById('modal-overlay'); + const closeModal = document.getElementById('close-modal'); + const loginForm = document.getElementById('login-form'); + const emailInput = document.getElementById('email'); + const emailError = document.getElementById('email-error'); + const emailSuccess = document.getElementById('email-success'); + const submitButton = document.getElementById('submit-button'); + + function showModal() { + modalOverlay.classList.add('show'); + } + + function hideModal() { + modalOverlay.classList.remove('show'); + emailInput.value = ''; + emailError.textContent = ''; + emailSuccess.textContent = ''; + } + + window.showLogin = showModal; + closeModal.addEventListener('click', hideModal); + modalOverlay.addEventListener('click', (e) => { + if (e.target === modalOverlay) hideModal(); + }); + + loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const email = emailInput.value.trim(); + emailError.textContent = ''; + emailSuccess.textContent = ''; + submitButton.disabled = true; + submitButton.textContent = 'Sending...'; + try { + const response = await fetch(apiUrl + '/auth/magiclink/request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, callbackUrl }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.message || 'Failed to send magic link'); + emailSuccess.textContent = 'Check your email for the magic link'; + submitButton.textContent = 'Magic link sent'; + } catch (error) { + emailError.textContent = error.message || 'Failed to send magic link. Please try again.'; + submitButton.textContent = 'Send magic link'; + } finally { + submitButton.disabled = false; + } + }); + + ${buttonScript} + + injectLoginLinkInternal(scalarApiReference, showModal); +})(); +` +} diff --git a/apps/fastify/src/routes/reference/template-styles.ts b/apps/fastify/src/routes/reference/template-styles.ts new file mode 100644 index 00000000..10c9880f --- /dev/null +++ b/apps/fastify/src/routes/reference/template-styles.ts @@ -0,0 +1,38 @@ +export const scalarStyles = ` + * { margin: 0; padding: 0; box-sizing: border-box; } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; } + #scalar-container { height: 100vh; width: 100vw; } + .modal-overlay { + display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.7); z-index: 20000; align-items: center; justify-content: center; + } + .modal-overlay.show { display: flex; } + .modal { + background: #1e1e1e; border-radius: 12px; padding: 32px; max-width: 400px; width: 90%; + max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.5); + border: 1px solid #333; + } + .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } + .modal-title { font-size: 24px; font-weight: 600; color: #e5e5e5; } + .close-button { + background: none; border: none; font-size: 24px; cursor: pointer; color: #999; + padding: 0; width: 32px; height: 32px; display: flex; align-items: center; + justify-content: center; border-radius: 4px; transition: background 0.2s; + } + .close-button:hover { background: #333; } + .form-group { margin-bottom: 20px; } + .form-label { display: block; margin-bottom: 8px; font-weight: 500; color: #e5e5e5; font-size: 14px; } + .form-input { + width: 100%; padding: 10px 12px; border: 1px solid #444; border-radius: 6px; + font-size: 14px; transition: border-color 0.2s; background: #2a2a2a; color: #e5e5e5; + } + .form-input:focus { outline: none; border-color: #667eea; } + .form-error { color: #ef4444; font-size: 12px; margin-top: 4px; } + .form-success { color: #22c55e; font-size: 12px; margin-top: 4px; } + .submit-button { + width: 100%; padding: 12px; background: #667eea; color: white; border: none; + border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 0.2s; + } + .submit-button:hover:not(:disabled) { background: #5568d3; } + .submit-button:disabled { background: #444; cursor: not-allowed; } +` diff --git a/apps/fastify/src/routes/reference/template.ts b/apps/fastify/src/routes/reference/template.ts index 2d5c679f..cba2189a 100644 --- a/apps/fastify/src/routes/reference/template.ts +++ b/apps/fastify/src/routes/reference/template.ts @@ -1,289 +1,5 @@ -const css = ` - * { margin: 0; padding: 0; box-sizing: border-box; } - body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; } - #scalar-container { height: 100vh; width: 100vw; } - .modal-overlay { - display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; - background: rgba(0,0,0,0.7); z-index: 20000; align-items: center; justify-content: center; - } - .modal-overlay.show { display: flex; } - .modal { - background: #1e1e1e; border-radius: 12px; padding: 32px; max-width: 400px; width: 90%; - max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.5); - border: 1px solid #333; - } - .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; } - .modal-title { font-size: 24px; font-weight: 600; color: #e5e5e5; } - .close-button { - background: none; border: none; font-size: 24px; cursor: pointer; color: #999; - padding: 0; width: 32px; height: 32px; display: flex; align-items: center; - justify-content: center; border-radius: 4px; transition: background 0.2s; - } - .close-button:hover { background: #333; } - .form-group { margin-bottom: 20px; } - .form-label { display: block; margin-bottom: 8px; font-weight: 500; color: #e5e5e5; font-size: 14px; } - .form-input { - width: 100%; padding: 10px 12px; border: 1px solid #444; border-radius: 6px; - font-size: 14px; transition: border-color 0.2s; background: #2a2a2a; color: #e5e5e5; - } - .form-input:focus { outline: none; border-color: #667eea; } - .form-error { color: #ef4444; font-size: 12px; margin-top: 4px; } - .form-success { color: #22c55e; font-size: 12px; margin-top: 4px; } - .submit-button { - width: 100%; padding: 12px; background: #667eea; color: white; border: none; - border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 0.2s; - } - .submit-button:hover:not(:disabled) { background: #5568d3; } - .submit-button:disabled { background: #444; cursor: not-allowed; } -` - -function getButtonInjectionScript(): string { - return ` - function findConfigureButton(container) { - let btn = Array.from(container.querySelectorAll('button')).find(b => b.textContent?.trim() === 'Configure'); - if (!btn) btn = Array.from(container.querySelectorAll('button')).find(b => b.textContent?.toLowerCase().trim() === 'configure'); - if (!btn) { - const buttons = Array.from(container.querySelectorAll('button')); - if (buttons.length === 0) return null; - btn = buttons[buttons.length - 1]; - } - return btn; - } - - function createLoginButton(configureButton, scalarApiReference, showModal) { - const link = document.createElement('button'); - const currentToken = localStorage.getItem('scalar-token'); - link.textContent = currentToken ? 'Logout' : 'Login'; - link.setAttribute('data-login-link', 'true'); - link.setAttribute('type', 'button'); - const style = window.getComputedStyle(configureButton); - const props = ['background', 'border', 'color', 'cursor', 'fontSize', 'fontFamily', 'fontWeight', 'fontStyle', 'letterSpacing', 'lineHeight', 'padding', 'margin', 'borderRadius', 'transition', 'display', 'alignItems', 'gap', 'textDecoration', 'textTransform']; - link.style.cssText = props.map(p => p + ': ' + style[p]).join('; ') + ';'; - if (configureButton.className) link.className = configureButton.className; - link.onmouseenter = () => { - const hover = window.getComputedStyle(configureButton, ':hover'); - if (hover.backgroundColor) link.style.backgroundColor = hover.backgroundColor; - }; - link.onmouseleave = () => link.style.backgroundColor = style.backgroundColor; - link.onclick = (e) => { - e.preventDefault(); - e.stopPropagation(); - const token = localStorage.getItem('scalar-token'); - if (token) { - localStorage.removeItem('scalar-token'); - updateScalarAuth(scalarApiReference, ''); - location.reload(); - } else { - showModal(); - } - }; - configureButton.insertAdjacentElement('afterend', link); - return true; - } - - function injectLoginLinkInternal(scalarApiReference, showModal) { - let injected = false; - let attempts = 0; - const maxAttempts = 150; - const tryInject = () => { - attempts++; - if (document.querySelector('[data-login-link]')) return true; - const devBtn = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === 'Developer Tools'); - if (devBtn) { - let container = devBtn.parentElement; - while (container && container !== document.body) { - const buttons = Array.from(container.querySelectorAll('button')); - if (buttons.length >= 4 && buttons.includes(devBtn)) { - const cfgBtn = findConfigureButton(container); - if (cfgBtn) return createLoginButton(cfgBtn, scalarApiReference, showModal); - } - container = container.parentElement; - } - if (devBtn.parentElement) { - const parentBtns = Array.from(devBtn.parentElement.querySelectorAll('button')); - if (parentBtns.length >= 2 && parentBtns.some(b => b.textContent?.trim() === 'Configure')) { - const cfgBtn = findConfigureButton(devBtn.parentElement); - if (cfgBtn) return createLoginButton(cfgBtn, scalarApiReference, showModal); - } - } - } - return false; - }; - window.updateLoginButton = () => { - const link = document.querySelector('[data-login-link]'); - if (link) link.textContent = localStorage.getItem('scalar-token') ? 'Logout' : 'Login'; - }; - if (tryInject()) return; - const observer = new MutationObserver(() => { - if (!injected && tryInject()) { - injected = true; - observer.disconnect(); - } - }); - observer.observe(document.body, { childList: true, subtree: true }); - const interval = setInterval(() => { - if (injected || tryInject() || attempts >= maxAttempts) { - clearInterval(interval); - observer.disconnect(); - } - }, 100); - }` -} - -function getInitScript(opts: { - apiUrl: string - openApiUrl: string - callbackUrl: string - jwtToken: string | null - verificationId?: string -}): string { - const { apiUrl, openApiUrl, callbackUrl, jwtToken, verificationId } = opts - const jwtJson = jwtToken ? JSON.stringify(jwtToken) : 'null' - const verificationIdJson = verificationId ? JSON.stringify(verificationId) : 'null' - const buttonScript = getButtonInjectionScript() - - return ` -(function() { - const apiUrl = ${JSON.stringify(apiUrl)}; - const callbackUrl = ${JSON.stringify(callbackUrl)}; - const openApiUrl = ${JSON.stringify(openApiUrl)}; - const jwtFromServer = ${jwtJson}; - const verificationIdFromUrl = ${verificationIdJson}; - - function updateScalarAuth(scalarApiReference, token) { - const authConfig = { - preferredSecurityScheme: 'bearerAuth', - securitySchemes: { bearerAuth: { token } }, - }; - if (scalarApiReference?.updateConfiguration) { - scalarApiReference.updateConfiguration({ authentication: authConfig }); - } else if (scalarApiReference?.updateAuthentication) { - scalarApiReference.updateAuthentication(authConfig); - } - } - - const storedToken = localStorage.getItem('scalar-token'); - const token = jwtFromServer || storedToken; - - let scalarApiReference = null; - try { - scalarApiReference = Scalar.createApiReference('#scalar-container', { - url: openApiUrl, - theme: 'moon', - authentication: { - preferredSecurityScheme: 'bearerAuth', - securitySchemes: { bearerAuth: { token: token || '' } }, - }, - }); - } catch (error) { - console.error('Failed to initialize Scalar:', error); - } - - if (jwtFromServer) { - localStorage.setItem('scalar-token', jwtFromServer); - history.replaceState({}, '', '/reference'); - updateScalarAuth(scalarApiReference, jwtFromServer); - } else if (verificationIdFromUrl) { - const banner = document.createElement('div'); - banner.id = 'verify-banner'; - banner.style.cssText = 'position:fixed;top:0;left:0;right:0;padding:12px 16px;background:#1e3a5f;color:#fff;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap;z-index:10000;font-size:14px;'; - banner.innerHTML = 'Enter the 6-digit code from your email:
'; - document.body.prepend(banner); - banner.querySelector('form')?.addEventListener('submit', async (e) => { - e.preventDefault(); - const input = banner.querySelector('input'); - const code = input?.value?.trim(); - if (!code || code.length !== 6) return; - const errorEl = banner.querySelector('[data-verify-error]'); - if (errorEl) errorEl.textContent = ''; - try { - const res = await fetch(apiUrl + '/auth/magiclink/verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ verificationId: verificationIdFromUrl, token: code }), - }); - if (res.ok) { - const data = await res.json(); - localStorage.setItem('scalar-token', data.token); - history.replaceState({}, '', '/reference'); - updateScalarAuth(scalarApiReference, data.token); - banner.remove(); - if (window.updateLoginButton) window.updateLoginButton(); - } else { - let msg = 'Verification failed. Please try again.'; - try { - const body = await res.json(); - msg = body.message || msg; - } catch {} - const errDiv = banner.querySelector('[data-verify-error]'); - if (errDiv) errDiv.textContent = msg; - } - } catch (err) { - const msg = 'Verification failed. Please try again.'; - const errDiv = banner.querySelector('[data-verify-error]'); - if (errDiv) errDiv.textContent = msg; - } - }); - } - - window.scalarApiReference = scalarApiReference; - - const modalOverlay = document.getElementById('modal-overlay'); - const closeModal = document.getElementById('close-modal'); - const loginForm = document.getElementById('login-form'); - const emailInput = document.getElementById('email'); - const emailError = document.getElementById('email-error'); - const emailSuccess = document.getElementById('email-success'); - const submitButton = document.getElementById('submit-button'); - - function showModal() { - modalOverlay.classList.add('show'); - } - - function hideModal() { - modalOverlay.classList.remove('show'); - emailInput.value = ''; - emailError.textContent = ''; - emailSuccess.textContent = ''; - } - - window.showLogin = showModal; - closeModal.addEventListener('click', hideModal); - modalOverlay.addEventListener('click', (e) => { - if (e.target === modalOverlay) hideModal(); - }); - - loginForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const email = emailInput.value.trim(); - emailError.textContent = ''; - emailSuccess.textContent = ''; - submitButton.disabled = true; - submitButton.textContent = 'Sending...'; - try { - const response = await fetch(apiUrl + '/auth/magiclink/request', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, callbackUrl }), - }); - const data = await response.json(); - if (!response.ok) throw new Error(data.message || 'Failed to send magic link'); - emailSuccess.textContent = 'Check your email for the magic link'; - submitButton.textContent = 'Magic link sent'; - } catch (error) { - emailError.textContent = error.message || 'Failed to send magic link. Please try again.'; - submitButton.textContent = 'Send magic link'; - } finally { - submitButton.disabled = false; - } - }); - - ${buttonScript} - - injectLoginLinkInternal(scalarApiReference, showModal); -})(); -` -} +import { getInitScript } from './template-scripts.js' +import { scalarStyles } from './template-styles.js' export function getReferenceHtml(opts: { apiUrl: string @@ -300,7 +16,7 @@ export function getReferenceHtml(opts: { API Reference - Basilic - diff --git a/apps/next/.env.development b/apps/next/.env.development index e5b7857b..8f455280 100644 --- a/apps/next/.env.development +++ b/apps/next/.env.development @@ -7,3 +7,6 @@ NEXT_PUBLIC_API_URL=http://localhost:3001 # Logging configuration for development LOG_ENABLED=true LOG_LEVEL=debug + +# Google One Tap (client-side). Same value as Fastify GOOGLE_CLIENT_ID. +NEXT_PUBLIC_GOOGLE_CLIENT_ID=767744818103-5vme8soap4h1p6hmactlv4gr54n0qvbd.apps.googleusercontent.com \ No newline at end of file diff --git a/apps/next/.env.local.example b/apps/next/.env.local.example index 5554e600..751bd53b 100644 --- a/apps/next/.env.local.example +++ b/apps/next/.env.local.example @@ -1,4 +1,7 @@ # JWT secret for token verification. Must match Fastify JWT_SECRET. JWT_SECRET=default-jwt-secret-min-32-chars-for-dev +# Optional: Google One Tap (client-side). Same value as Fastify GOOGLE_CLIENT_ID. +# NEXT_PUBLIC_GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com + NEWSAPI_KEY=xxxxxxx \ No newline at end of file diff --git a/apps/next/.env.production b/apps/next/.env.production index 5182aa0b..507a0fd2 100644 --- a/apps/next/.env.production +++ b/apps/next/.env.production @@ -8,3 +8,6 @@ NEXT_PUBLIC_API_URL=https://basilic-fastify.vercel.app # JWT_SECRET must be set via env (Vercel/deployment) - not committed. Use same value as Fastify. # For local prod build: export JWT_SECRET='your-32-char-secret' or add to .env.local + + +NEXT_PUBLIC_GOOGLE_CLIENT_ID=767744818103-5vme8soap4h1p6hmactlv4gr54n0qvbd.apps.googleusercontent.com \ No newline at end of file diff --git a/apps/next/.env.staging b/apps/next/.env.staging index 8b221875..b0d03db5 100644 --- a/apps/next/.env.staging +++ b/apps/next/.env.staging @@ -3,3 +3,4 @@ # Required: API base URL NEXT_PUBLIC_API_URL=https://basilic-fastify.vercel.app +NEXT_PUBLIC_GOOGLE_CLIENT_ID=767744818103-5vme8soap4h1p6hmactlv4gr54n0qvbd.apps.googleusercontent.com \ No newline at end of file diff --git a/apps/next/app/(dashboard)/settings/(profile)/linked-accounts-section.tsx b/apps/next/app/(dashboard)/settings/(profile)/linked-accounts-section.tsx index 1a588382..ee1764f0 100644 --- a/apps/next/app/(dashboard)/settings/(profile)/linked-accounts-section.tsx +++ b/apps/next/app/(dashboard)/settings/(profile)/linked-accounts-section.tsx @@ -1,6 +1,6 @@ 'use client' -import { useOAuthLink, useOAuthUnlink, useUser } from '@repo/react' +import { useOAuthLink, useOAuthProviders, useOAuthUnlink, useUser } from '@repo/react' import { AlertDialog, AlertDialogAction, @@ -26,6 +26,12 @@ const providerLabels: Record = { export function LinkedAccountsSection() { const { data } = useUser() + const { + github: githubEnabled, + googleRedirect, + facebook: facebookEnabled, + twitter: twitterEnabled, + } = useOAuthProviders() const unlinkMutation = useOAuthUnlink() const [confirmUnlink, setConfirmUnlink] = useSetState<{ providerId: string | null }>({ providerId: null, @@ -87,7 +93,16 @@ export function LinkedAccountsSection() { isOpen={confirmUnlink.providerId === providerId} /> ) : ( - + )} ) @@ -138,12 +153,22 @@ function UnlinkButton({ ) } -const linkableProviders = ['github', 'facebook', 'twitter'] as const +const linkProviderIds = ['github', 'google', 'facebook', 'twitter'] as const -function LinkProviderButton({ providerId, label }: { providerId: string; label: string }) { - const canLink = linkableProviders.includes(providerId as (typeof linkableProviders)[number]) +function LinkProviderButton({ + providerId, + label, + providersEnabled, +}: { + providerId: string + label: string + providersEnabled: { github: boolean; google: boolean; facebook: boolean; twitter: boolean } +}) { + const canLink = + linkProviderIds.includes(providerId as (typeof linkProviderIds)[number]) && + (providersEnabled[providerId as keyof typeof providersEnabled] ?? false) const linkMutation = useOAuthLink( - canLink ? (providerId as (typeof linkableProviders)[number]) : 'github', + canLink ? (providerId as (typeof linkProviderIds)[number]) : 'github', ) const handleLink = useCallback(() => { diff --git a/apps/next/app/auth/callback/oauth/google/route.ts b/apps/next/app/auth/callback/oauth/google/route.ts new file mode 100644 index 00000000..5478b893 --- /dev/null +++ b/apps/next/app/auth/callback/oauth/google/route.ts @@ -0,0 +1,59 @@ +import { ApiError, createClient } from '@repo/core' +import { cookies } from 'next/headers' +import { NextResponse } from 'next/server' +import { translateOAuthError } from '@/lib/auth/auth-error-messages' +import { setAuthCookiesOnResponse } from '@/lib/auth/auth-server' +import { extractTokens, getOAuthRedirectTarget } from '@/lib/auth/callback-utils' +import { parseAuthCookie } from '@/lib/auth/parse-auth-cookie' +import { env } from '@/lib/env' + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const code = searchParams.get('code') + const state = searchParams.get('state') + + if (!code || !state) + return NextResponse.redirect( + new URL(`/auth/login?message=${encodeURIComponent('missing_params')}`, request.url), + 303, + ) + + const cookieStore = await cookies() + const { token } = parseAuthCookie(cookieStore.get(env.NEXT_PUBLIC_AUTH_COOKIE_NAME)?.value) + const client = createClient({ + baseUrl: env.NEXT_PUBLIC_API_URL, + ...(token && { + getAuthToken: () => token, + getRefreshToken: () => null, + onTokensRefreshed: async () => {}, + }), + }) + + try { + const response = await client.auth.oauth.google.exchange({ + body: { code, state }, + throwOnError: true, + }) + const tokens = extractTokens(response) + if (!tokens) + return NextResponse.redirect( + new URL(`/auth/login?message=${encodeURIComponent('oauth_failed_google')}`, request.url), + 303, + ) + + const redirectResponse = NextResponse.redirect( + new URL(getOAuthRedirectTarget(response), request.url), + 303, + ) + setAuthCookiesOnResponse(redirectResponse, tokens) + return redirectResponse + } catch (error) { + const rawMessage = error instanceof Error ? error.message : 'Google sign-in failed' + const body = error instanceof ApiError ? error.body : undefined + const errorCode = translateOAuthError(rawMessage, body, 'google') + return NextResponse.redirect( + new URL(`/auth/login?message=${encodeURIComponent(errorCode)}`, request.url), + 303, + ) + } +} diff --git a/apps/next/app/auth/login/login-actions.tsx b/apps/next/app/auth/login/login-actions.tsx index e41b6baa..6f82d5f6 100644 --- a/apps/next/app/auth/login/login-actions.tsx +++ b/apps/next/app/auth/login/login-actions.tsx @@ -1,6 +1,5 @@ 'use client' -import { ApiError } from '@repo/core' import { useOAuthLogin, useOAuthProviders, @@ -12,11 +11,10 @@ import { Alert, AlertDescription, AlertTitle } from '@repo/ui/components/alert' import { Button } from '@repo/ui/components/button' import { X } from 'lucide-react' import { useRouter } from 'next/navigation' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { toast } from 'sonner' import { Facebook, GitHub, Google, Passkey, Twitter } from '@/components/icons' import { updateAuthTokens } from '@/lib/auth/auth-client' -import { getAuthErrorMessage } from '@/lib/auth/auth-error-messages' import { LoginForm } from './login-form' import { PasskeyShortcut } from './passkey-shortcut' import { useGoogleOneTap } from './use-google-one-tap' @@ -26,11 +24,10 @@ type LoginActionsProps = { initialError?: string } type OAuthButtonsProps = { anyPending: boolean setLastAuthMethod: (m: 'oauth' | 'passkey') => void - startOAuthLogin: (p: 'github' | 'facebook' | 'twitter') => void - promptGoogle: () => void + startOAuthLogin: (p: 'github' | 'google' | 'facebook' | 'twitter') => void + onGoogleClick: () => void isGithubConfigured: boolean isGoogleConfigured: boolean - isGoogleReady: boolean isFacebookConfigured: boolean isTwitterConfigured: boolean isOAuthPending: boolean @@ -44,10 +41,9 @@ function OAuthButtons({ anyPending, setLastAuthMethod, startOAuthLogin, - promptGoogle, + onGoogleClick, isGithubConfigured, isGoogleConfigured, - isGoogleReady, isFacebookConfigured, isTwitterConfigured, isOAuthPending, @@ -88,12 +84,12 @@ function OAuthButtons({