From c856904c121f44ef6af511d919989dfa0ba3e85c Mon Sep 17 00:00:00 2001 From: gunshiz Date: Tue, 6 Oct 2026 00:48:29 +0700 Subject: [PATCH] feat(auth) : sync Sudloh profiles and validate sessions --- .agents/skills/sudloh-oidc/SKILL.md | 4 +- .agents/skills/sudloh-oidc/references/api.md | 25 ++- .env.example | 6 +- README.md | 5 +- app/api/auth/[...all]/route.test.ts | 48 ++++++ app/api/auth/[...all]/route.ts | 41 ++++- app/api/profile/route.test.ts | 13 +- app/api/profile/route.ts | 7 +- app/api/profile/sync/route.ts | 16 ++ app/profile/page.tsx | 14 +- components/auth/sudloh-profile.tsx | 59 +++++++ lib/auth/server.test.ts | 20 +++ lib/auth/server.ts | 21 ++- lib/auth/sudloh.test.ts | 104 ++++++++++++ lib/auth/sudloh.ts | 164 +++++++++++++++++++ lib/commission/server.ts | 5 +- 16 files changed, 520 insertions(+), 32 deletions(-) create mode 100644 app/api/auth/[...all]/route.test.ts create mode 100644 app/api/profile/sync/route.ts create mode 100644 components/auth/sudloh-profile.tsx create mode 100644 lib/auth/sudloh.test.ts create mode 100644 lib/auth/sudloh.ts diff --git a/.agents/skills/sudloh-oidc/SKILL.md b/.agents/skills/sudloh-oidc/SKILL.md index 0356909..bdd8c64 100644 --- a/.agents/skills/sudloh-oidc/SKILL.md +++ b/.agents/skills/sudloh-oidc/SKILL.md @@ -7,4 +7,6 @@ description: Integrate a Sudloh application or API with account.sudloh.com's OID Read [references/api.md](references/api.md) before implementing a client or protected API. It defines the issuer, endpoints, security checks, credentials, examples, and sign-out behavior. Treat OIDC discovery as the current source of endpoint URLs at runtime. -For Guide and Buzz, request the exact HTTPS callback URL and the assigned `client_id` and `client_secret` from the account service operator. Each app owns its own session. Do not send a browser's account service cookie to an app API or treat an ID token as an API access token. +For any project, determine its exact HTTPS callback URL first. A verified Sudloh Account administrator can sign in at `https://account.sudloh.com/account`, open **OIDC clients**, and create a client with a project name and that callback URL. Copy the new `client_id` and `client_secret` from the result immediately; the secret is shown only at creation. Give them only to that project's server and register a separate client for each project. The command line `client:register` script remains available for Guide and Buzz operators. Each app owns its own session. Do not send a browser's account service cookie to an app API or treat an ID token as an API access token. + +For Guide profile syncing and revocation checks, use the account settings URL, UserInfo, and authenticated introspection contract in [references/api.md](references/api.md). The current `openid profile email` grant has no refresh token, so reauthorize when its access token expires. diff --git a/.agents/skills/sudloh-oidc/references/api.md b/.agents/skills/sudloh-oidc/references/api.md index b7b7e53..9e238c6 100644 --- a/.agents/skills/sudloh-oidc/references/api.md +++ b/.agents/skills/sudloh-oidc/references/api.md @@ -7,9 +7,9 @@ - OAuth metadata: `https://account.sudloh.com/api/auth/.well-known/oauth-authorization-server` - Supported scopes for first-party web clients: `openid profile email` - Stable user key: the validated ID token's `sub`. Store it as the app's external identity key; do not use email as a primary key. -- Public client registration is disabled. Guide and Buzz are pre-registered, confidential web clients with exact HTTPS redirect URIs, `client_secret_basic`, authorization-code grant, and S256 PKCE. Their consent screen is skipped. +- Public client registration is disabled. A verified Sudloh Account administrator can create a confidential web client for any project at `/admin/clients`, using its exact HTTPS callback URL. Clients use `client_secret_basic`, authorization-code grant, and S256 PKCE. New clients require user consent unless an administrator changes their configuration. Guide and Buzz use separate clients. -The discovery document gives the actual authorization, token, UserInfo, JWKS, revocation, and end-session URLs. At the current version, the authorization endpoint is `/api/auth/oauth2/authorize`, token endpoint is `/api/auth/oauth2/token`, UserInfo endpoint is `/api/auth/oauth2/userinfo`, and keys are at `/api/auth/jwks`, all under `https://account.sudloh.com`. Read discovery rather than hard-coding those paths. +The discovery document gives the actual authorization, token, UserInfo, introspection, JWKS, revocation, and end-session URLs. At the current version, the authorization endpoint is `/api/auth/oauth2/authorize`, token endpoint is `/api/auth/oauth2/token`, UserInfo endpoint is `/api/auth/oauth2/userinfo`, introspection endpoint is `/api/auth/oauth2/introspect`, and keys are at `/api/auth/jwks`, all under `https://account.sudloh.com`. Read discovery rather than hard-coding those paths. | Endpoint | Method | Caller and purpose | | --- | --- | --- | @@ -18,6 +18,7 @@ The discovery document gives the actual authorization, token, UserInfo, JWKS, re | `/oauth2/token` under the issuer | POST | Server-to-server code exchange with HTTP Basic client authentication | | `/jwks` under the issuer | GET | Public signing keys for ID-token and JWT verification | | `/oauth2/userinfo` under the issuer | GET | Bearer access token; returns permitted user claims | +| `/oauth2/introspect` under the issuer | POST | Confidential client authentication; checks its issued access token | | `/oauth2/revoke` under the issuer | POST | Revokes a token; authenticate the client as advertised by discovery | | `/oauth2/end-session` under the issuer | GET/POST | Optional OIDC session-end flow for that client | @@ -30,13 +31,13 @@ For each app, configure these server-side secrets and settings: | Setting | Value | | --- | --- | | `SUDLOH_OIDC_ISSUER` | `https://account.sudloh.com/api/auth` | -| `SUDLOH_OIDC_CLIENT_ID` | Assigned during client registration | -| `SUDLOH_OIDC_CLIENT_SECRET` | Assigned once during client registration; server-side only | +| `SUDLOH_OIDC_CLIENT_ID` | Shown in the admin section when the client is created | +| `SUDLOH_OIDC_CLIENT_SECRET` | Shown once in the admin section when the client is created; server-side only | | `SUDLOH_OIDC_REDIRECT_URI` | Exact registered HTTPS callback URL on that app | | scopes | `openid profile email` | | client authentication | HTTP Basic at token endpoint (`client_secret_basic`) | -Guide and Buzz callback URLs are intentionally not fixed in the account repository. The owner of each app must give its actual callback URL to the account operator before registration. Never use a wildcard callback URL. Keep the client secret out of browser code and logs. +The owner of each app must supply its actual callback URL before registration. Sign in as the verified account administrator, open **Account settings → OIDC clients**, enter the project name and exact HTTPS callback URL, then copy the new ID and secret into that project's server configuration. Never use a wildcard callback URL. Keep the client secret out of browser code and logs. A lost secret requires rotation or a replacement client; the list does not reveal it again. ## Browser sign-in sequence @@ -53,7 +54,7 @@ The code exchange is a `POST` with `Content-Type: application/x-www-form-urlenco grant_type=authorization_code&code=&redirect_uri=&code_verifier= ``` -The successful JSON response contains `access_token`, `token_type`, `expires_in`, `id_token`, and granted `scope`. Do not assume a refresh token: these clients request no `offline_access`. Call UserInfo with `Authorization: Bearer ` only when fresh claims are needed. The ID token contains standard claims including `iss`, `sub`, `aud`, `exp`, `iat`, and the request's `nonce`; profile and email claims depend on the granted scopes. +The successful JSON response contains `access_token`, `token_type`, `expires_in`, `id_token`, and granted `scope`. Guide's current `openid profile email` grant has no refresh token. The ID token contains standard claims including `iss`, `sub`, `aud`, `exp`, `iat`, and the request's `nonce`. With `profile email`, both a newly issued ID token and UserInfo contain `sub` (stable Sudloh user ID), `name` (string), `picture` (absolute public URL), `email` (string), and `email_verified` (boolean). The picture is a Sudloh default avatar URL if the user has no image. An ID token is immutable; only a newly issued token reflects changes made after issuance. The provider requires exact redirect URI matching and S256 PKCE. Clients should use a maintained OIDC library for state, nonce, PKCE, token exchange, and ID-token verification. @@ -85,11 +86,17 @@ Call `authClient.signIn.social({ provider: "sudloh" })` in the client app. Confi An ID token is for the OAuth client; do not use it to authorize API requests. If an API accepts Sudloh access tokens, validate a JWT against discovery's JWKS and require the expected issuer, expiry, audience/resource, and scope. Register that API as an OAuth protected resource before requesting resource-bound tokens. For the initial Guide and Buzz sign-in integration, use app-owned sessions and do not expose an account access token to the browser API layer. -## Profile updates and sign-out +## Profile updates and active Guide sessions -Email change completes only after the user verifies the new address. Existing app sessions can have older profile claims until their next sign-in or profile refresh. Use `sub` to correlate the user when email changes. +Send users to the stable account settings URL `https://account.sudloh.com/account` to change name, picture, or email. The account page requires a Sudloh sign-in. Picture uploads are stored in the configured Bun S3 bucket and served from its public URL. An email change stays pending until the new address's verification link is opened; only then does UserInfo and a subsequently issued ID token expose the new email. Use `sub` to correlate the user across email changes. -Account sign-out or account-session revocation prevents that account session from initiating future OIDC flows. It does not immediately end Guide or Buzz sessions. Each client app must provide its own sign-out. The provider advertises an OIDC end-session endpoint, but there is no cross-app back-channel logout in this version. +For fresh claims after sign-in, Guide's server calls the discovered `userinfo_endpoint` with `GET` and `Authorization: Bearer `. UserInfo reads the current Sudloh user, so the same active access token can return a changed name, picture, or verified email. Require its `sub` to equal the validated ID token's `sub`, and update Guide's cached profile fields. Call after initial sign-in and after the user returns from account settings or otherwise needs a profile refresh. Keep the access token server-side; do not send it to Guide browser code. + +Sudloh supports RFC 7662 introspection for Guide's own access token. While a Guide session is in use, check on the first protected request after each five-minute cache interval, including the first request after a quiet period. `POST` to discovery's `introspection_endpoint` with `Content-Type: application/x-www-form-urlencoded`, `Authorization: Basic `, and body `token=&token_type_hint=access_token`. Require `active: true`, expected `sub`, and `exp` later than the current time. An ended Sudloh Account session makes its issued access token inactive. If the response is inactive, end the Guide session. On an introspection timeout, network failure, malformed response, or server error, deny protected Guide requests until a successful check; never treat a failed check as active. Cache a successful check for no longer than five minutes. + +The current access token expires after the issued `expires_in` (normally 3600 seconds). Guide does not request `offline_access`, so it cannot refresh that token. At expiry, restart the authorization-code flow and obtain a new token set before continuing the Guide session. If reauthorization fails or is canceled, end the Guide session. Do not use an ID token or Sudloh browser cookie for UserInfo, introspection, or Guide API authorization. + +Account sign-out or account-session revocation makes the bound access token inactive, but it cannot directly delete Guide's independent session. Guide must enforce the checks above and provide its own sign-out. The provider advertises an OIDC end-session endpoint; this integration does not rely on cross-app back-channel logout. ## Errors and checks diff --git a/.env.example b/.env.example index 277435f..a26a293 100644 --- a/.env.example +++ b/.env.example @@ -14,13 +14,13 @@ BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes # Separate trusted browser origins with commas for local development or proxies. BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 -# Optional Sudloh Account sign-in. Register the exact HTTPS callback before enabling. -# Leave client ID and secret unset until the account service operator assigns them. +# Optional Sudloh Account sign-in. A verified Sudloh administrator registers the +# exact HTTPS callback at https://account.sudloh.com/account → OIDC clients. SUDLOH_OIDC_ISSUER=https://account.sudloh.com/api/auth SUDLOH_OIDC_CLIENT_ID= SUDLOH_OIDC_CLIENT_SECRET= SUDLOH_OIDC_REDIRECT_URI= -# Set only after migration and conflict review to route all new sign-ins through Sudloh. +# Set after linking existing Guide accounts; this also enables Sudloh session checks. SUDLOH_OIDC_ONLY=false # Resend sending key; verify sudloh.com before sending from no-reply@sudloh.com. diff --git a/README.md b/README.md index d485ec1..b0d17e9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,10 @@ endorsed by HoYoverse. The repository and deployment resources retain the constellations, teams, and custom sections, with autosave and conflict recovery. - **Accounts:** Sudloh Account OIDC sign-in with local Buzz sessions, IDs, and roles. Legacy email/password and registration OTP remain available only before - `SUDLOH_OIDC_ONLY=true` cutover. + `SUDLOH_OIDC_ONLY=true` cutover. Linked profiles are managed at Sudloh Account; + Guide refreshes their profile from UserInfo and checks Sudloh token activity on + protected requests at least every five minutes. Access tokens expire after + about an hour and require a new Sudloh authorization flow. - **Media:** S3-compatible uploads and publication-aware delivery for staged files. - **Catalog updates:** Discord-triggered synchronization with Lunaris. - **Commissions:** PromptPay checkout, Slip2Go verification, ticket attachments, diff --git a/app/api/auth/[...all]/route.test.ts b/app/api/auth/[...all]/route.test.ts new file mode 100644 index 0000000..df808e6 --- /dev/null +++ b/app/api/auth/[...all]/route.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const session = vi.fn(); +const validate = vi.fn(); +const handler = vi.fn(async () => Response.json({ passed: true })); + +vi.mock("server-only", () => ({})); +vi.mock("better-auth/next-js", () => ({ toNextJsHandler: () => ({ GET: handler }) })); +vi.mock("@/lib/auth/server", () => ({ + getAuth: () => ({ api: { getSession: session }, handler }), + isSudlohOidcEnabled: () => true, +})); +vi.mock("@/lib/auth/sudloh", () => ({ validateSudlohSession: validate })); + +const { GET, POST } = await import("./route"); + +beforeEach(() => { + vi.clearAllMocks(); + process.env.BETTER_AUTH_URL = "https://guide.sudloh.com"; + process.env.SUDLOH_OIDC_ONLY = "true"; + session.mockResolvedValue({ user: { id: "user-1" }, session: { id: "session-1" } }); + validate.mockResolvedValue(false); +}); + +describe("Better Auth Sudloh boundary", () => { + it("returns no browser session after Sudloh revokes the bound token", async () => { + const response = await GET(new Request("https://guide.sudloh.com/api/auth/get-session")); + expect(response.status).toBe(200); + expect(await response.json()).toBeNull(); + expect(handler).not.toHaveBeenCalled(); + }); + + it("denies other account endpoints but permits the OIDC callback", async () => { + const denied = await GET(new Request("https://guide.sudloh.com/api/auth/list-sessions")); + expect(denied.status).toBe(401); + const callback = await GET(new Request("https://guide.sudloh.com/api/auth/callback/sudloh?code=code")); + expect(callback.status).toBe(200); + }); + + it("denies Better Auth mutations with a revoked local session", async () => { + const response = await POST(new Request("https://guide.sudloh.com/api/auth/admin/create-user", { + method: "POST", headers: { Origin: "https://guide.sudloh.com", "Content-Type": "application/json" }, + body: JSON.stringify({ password: "example-password" }), + })); + expect(response.status).toBe(401); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts index 1531024..f31230a 100644 --- a/app/api/auth/[...all]/route.ts +++ b/app/api/auth/[...all]/route.ts @@ -1,17 +1,54 @@ import { toNextJsHandler } from "better-auth/next-js"; +import { and, eq } from "drizzle-orm"; -import { getAuth } from "@/lib/auth/server"; +import { getAuth, isSudlohOidcEnabled } from "@/lib/auth/server"; +import { validateSudlohSession } from "@/lib/auth/sudloh"; +import { getDb } from "@/db"; +import { accounts } from "@/db/schema"; import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http"; const handlers = toNextJsHandler((request) => getAuth().handler(request)); -export const GET = handlers.GET; +async function hasActiveSudlohSession(request: Request): Promise { + if (!isSudlohOidcEnabled() || process.env.SUDLOH_OIDC_ONLY !== "true") return null; + const session = await getAuth().api.getSession({ headers: request.headers }); + if (!session) return null; + return validateSudlohSession(session.user.id, session.session.id); +} + +export async function GET(request: Request) { + const path = new URL(request.url).pathname; + if (!path.endsWith("/callback/sudloh")) { + try { + const active = await hasActiveSudlohSession(request); + if (active === false) { + if (path.endsWith("/get-session")) + return Response.json(null, { headers: { "Cache-Control": "no-store" } }); + throw new HttpError(401, "unauthorized"); + } + } catch (cause) { return errorResponse(cause); } + } + return handlers.GET(request); +} async function mutate(request: Request) { try { requireSameOrigin(request); const input = await readJson(request.clone()); const path = new URL(request.url).pathname; + if (!path.endsWith("/sign-in/social") && !path.endsWith("/sign-out") && + await hasActiveSudlohSession(request) === false) throw new HttpError(401, "unauthorized"); + if (["/update-user", "/change-email"].some((endpoint) => path.endsWith(endpoint))) { + if (isSudlohOidcEnabled() && process.env.SUDLOH_OIDC_ONLY === "true") + throw new HttpError(403, "manage-profile-at-sudloh"); + const session = await getAuth().api.getSession({ headers: request.headers }); + if (session) { + const [linked] = await getDb().select({ id: accounts.id }).from(accounts).where(and( + eq(accounts.userId, session.user.id), eq(accounts.providerId, "sudloh"), + )).limit(1); + if (linked) throw new HttpError(403, "manage-profile-at-sudloh"); + } + } if (["/admin/create-user", "/admin/set-user-password"].some((endpoint) => path.endsWith(endpoint))) { const password = input && typeof input === "object" && "password" in input ? input.password : undefined; const newPassword = input && typeof input === "object" && "newPassword" in input ? input.newPassword : undefined; diff --git a/app/api/profile/route.test.ts b/app/api/profile/route.test.ts index 764bc0e..51e1515 100644 --- a/app/api/profile/route.test.ts +++ b/app/api/profile/route.test.ts @@ -7,9 +7,13 @@ const returning = vi.fn(); const where = vi.fn(() => ({ returning })); const set = vi.fn(() => ({ where })); const write = vi.fn(); +const linkedAccounts = vi.fn(); vi.mock("@/lib/commission/server", () => ({ requireCommissionUser })); -vi.mock("@/db", () => ({ getDb: () => ({ update: () => ({ set }) }) })); +vi.mock("@/db", () => ({ getDb: () => ({ + update: () => ({ set }), + select: () => ({ from: () => ({ where: () => ({ limit: linkedAccounts }) }) }), +}) })); vi.mock("@/lib/media/storage", () => ({ getMediaStorage: async () => ({ write }), publicMediaUrl: (key: string) => `https://cdn.test/${key}` })); vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: async () => undefined })); @@ -27,6 +31,7 @@ describe("profile update", () => { process.env.BETTER_AUTH_URL = "https://guide.sudloh.com"; vi.clearAllMocks(); requireCommissionUser.mockResolvedValue({ id: "user-1", image: null }); + linkedAccounts.mockResolvedValue([]); returning.mockResolvedValue([{ name: "New Name", image: null }]); }); @@ -60,4 +65,10 @@ describe("profile update", () => { expect((await POST(profileRequest("New Name"))).status).toBe(401); expect(set).not.toHaveBeenCalled(); }); + + it("sends Sudloh-linked users to Sudloh for profile changes", async () => { + linkedAccounts.mockResolvedValueOnce([{ id: "sudloh-account" }]); + expect((await POST(profileRequest("New Name"))).status).toBe(403); + expect(set).not.toHaveBeenCalled(); + }); }); diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts index 180bce1..ae5abe4 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -1,7 +1,7 @@ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import sharp from "sharp"; import { getDb } from "@/db"; -import { users } from "@/db/schema"; +import { accounts, users } from "@/db/schema"; import { requireCommissionUser } from "@/lib/commission/server"; import { inspectImage } from "@/lib/media/inspect"; import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage"; @@ -15,6 +15,9 @@ export async function POST(request: Request) { try { requireSameOrigin(request); const user = await requireCommissionUser(); + const [sudloh] = await getDb().select({ id: accounts.id }).from(accounts) + .where(and(eq(accounts.userId, user.id), eq(accounts.providerId, "sudloh"))).limit(1); + if (sudloh) throw new HttpError(403, "manage-profile-at-sudloh"); await limitRequest("profile-update", user.id, 20); if (!request.headers.get("content-type")?.startsWith("multipart/form-data;")) throw new HttpError(415, "expected-multipart"); diff --git a/app/api/profile/sync/route.ts b/app/api/profile/sync/route.ts new file mode 100644 index 0000000..c2b05dd --- /dev/null +++ b/app/api/profile/sync/route.ts @@ -0,0 +1,16 @@ +import { getCustomerSession, isSudlohOidcEnabled } from "@/lib/auth/server"; +import { refreshLinkedSudlohProfile, validateSudlohSession } from "@/lib/auth/sudloh"; +import { errorResponse, HttpError, requireSameOrigin } from "@/lib/security/http"; + +export async function POST(request: Request) { + try { + requireSameOrigin(request); + const session = await getCustomerSession(); + if (!session) throw new HttpError(401, "unauthorized"); + if (isSudlohOidcEnabled() && process.env.SUDLOH_OIDC_ONLY === "true") { + if (!await validateSudlohSession(session.user.id, session.session.id, true)) + throw new HttpError(401, "sudloh-session-expired"); + } else await refreshLinkedSudlohProfile(session.user.id); + return Response.json({ ok: true }, { headers: { "Cache-Control": "no-store" } }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/profile/page.tsx b/app/profile/page.tsx index 42d8183..e116192 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -9,9 +9,11 @@ import { ProfileForm } from "@/components/auth/profile-form"; import { PasswordForm } from "@/components/auth/password-form"; import { EmailSettings } from "@/components/auth/email-settings"; import { SudlohConnection } from "@/components/auth/sudloh-connection"; +import { SudlohProfile } from "@/components/auth/sudloh-profile"; import { isAuthorizedAdmin } from "@/lib/auth/authorization"; import { getCustomerSession, isSudlohOidcEnabled } from "@/lib/auth/server"; import { safeAuthReturnPath } from "@/lib/auth/return-path"; +import { ACCOUNT_SETTINGS_URL } from "@/lib/auth/sudloh"; export const instant = false; @@ -42,15 +44,13 @@ export default async function ProfilePage({ searchParams }: PageProps<"/profile" "เพิ่มรูปโปรไฟล์หรือแก้ชื่อที่แสดงก่อนเริ่มใช้งาน"}

}
- - {oidcEnabled && + : } + {oidcEnabled && !hasSudloh && } - {(!hasSudloh || hasCredential) && } - {(!hasSudloh || hasCredential) && } - {hasSudloh && !hasCredential &&

- จัดการอีเมลและรหัสผ่านของคุณผ่าน Sudloh Account -

} + {!hasSudloh && hasCredential && }
; diff --git a/components/auth/sudloh-profile.tsx b/components/auth/sudloh-profile.tsx new file mode 100644 index 0000000..af74770 --- /dev/null +++ b/components/auth/sudloh-profile.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useState } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { authClient } from "@/lib/auth/client"; + +export function SudlohProfile({ name, email, image, accountUrl, callbackError }: { + name: string; email: string; image: string | null; accountUrl: string; callbackError: boolean; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + async function refresh() { + setBusy(true); + setError(""); + try { + const response = await fetch("/api/profile/sync", { method: "POST" }); + if (response.status === 401) { + const result = await authClient.signIn.social({ provider: "sudloh", callbackURL: "/profile", + errorCallbackURL: "/profile?sudloh=error" }); + if (result.error) throw new Error("เข้าสู่ระบบ Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง"); + return; + } + if (response.status === 409) throw new Error("อีเมลนี้ถูกใช้โดยบัญชี Buzz Guide อื่น กรุณาติดต่อผู้ดูแล"); + if (!response.ok) throw new Error("อัปเดตข้อมูลจาก Sudloh ไม่สำเร็จ กรุณาเข้าสู่ระบบใหม่แล้วลองอีกครั้ง"); + window.location.reload(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "อัปเดตข้อมูลจาก Sudloh ไม่สำเร็จ"); + setBusy(false); + } + } + + return + ข้อมูลบัญชี + จัดการชื่อ รูปโปรไฟล์ และอีเมลที่ Sudloh Account + + + + {image && } + {name.trim().charAt(0).toUpperCase() || "?"} + +

{name}

+

{email}

+
+
+ + + + {(error || callbackError) && + {error || "เข้าสู่ระบบ Sudloh ไม่สำเร็จ กรุณาลองอีกครั้ง"} + } + +
; +} diff --git a/lib/auth/server.test.ts b/lib/auth/server.test.ts index 7eb275b..7c6fdb4 100644 --- a/lib/auth/server.test.ts +++ b/lib/auth/server.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = { session: vi.fn(), auth: vi.fn() }; +const sudloh = { bind: vi.fn(), validate: vi.fn() }; vi.mock("server-only", () => ({})); vi.mock("@/db", () => ({ getDb: () => ({}) })); vi.mock("next/headers", () => ({ headers: async () => new Headers() })); @@ -10,6 +11,7 @@ vi.mock("better-auth/next-js", () => ({ nextCookies: () => ({}) })); vi.mock("better-auth/plugins", () => ({ admin: (options: unknown) => options, captcha: () => ({}), genericOAuth: (options: unknown) => ({ id: "generic-oauth", options }), emailOTP: (options: unknown) => ({ id: "email-otp", options }) })); +vi.mock("./sudloh", () => ({ bindSudlohSession: sudloh.bind, validateSudlohSession: sudloh.validate })); const envNames = ["NODE_ENV", "DATABASE_URL", "BETTER_AUTH_URL", "BETTER_AUTH_SECRET", "BUZZ_DEMO_MODE", "RESEND_API_KEY", "SUDLOH_OIDC_CLIENT_ID", "SUDLOH_OIDC_CLIENT_SECRET", "SUDLOH_OIDC_REDIRECT_URI", "SUDLOH_OIDC_ISSUER", "SUDLOH_OIDC_ONLY"] as const; const testEnv = process.env as Record; @@ -23,6 +25,8 @@ beforeEach(() => { testEnv.BETTER_AUTH_SECRET = "a-test-secret-with-more-than-32-characters"; mocks.auth.mockReturnValue({ api: { getSession: mocks.session } }); mocks.session.mockResolvedValue(null); + sudloh.bind.mockReset(); + sudloh.validate.mockReset().mockResolvedValue(true); delete testEnv.SUDLOH_OIDC_CLIENT_ID; delete testEnv.SUDLOH_OIDC_CLIENT_SECRET; delete testEnv.SUDLOH_OIDC_REDIRECT_URI; @@ -77,6 +81,7 @@ describe("actual administrator session boundary", () => { expect(options.plugins.find((plugin: { id?: string }) => plugin.id === "generic-oauth").options.config[0]).toMatchObject({ providerId: "sudloh", authentication: "basic", requireIdTokenVerification: true, requireEmailVerification: true, disableProviderLogout: true, + overrideUserInfo: true, scopes: ["openid", "profile", "email"], redirectURI: "https://guide.example.test/api/auth/callback/sudloh", }); @@ -90,6 +95,21 @@ describe("actual administrator session boundary", () => { const options = mocks.auth.mock.calls.at(-1)![0]; expect(options.emailAndPassword).toMatchObject({ enabled: false, disableSignUp: true }); expect(options.plugins.some((plugin: { id?: string }) => plugin.id === "email-otp")).toBe(false); + await options.databaseHooks.session.create.after({ id: "guide-session", userId: "user-1" }, + { path: "/callback/sudloh" }); + expect(sudloh.bind).toHaveBeenCalledWith("user-1", "guide-session"); + }); + it("denies a revoked Sudloh session", async () => { + testEnv.SUDLOH_OIDC_CLIENT_ID = "client"; + testEnv.SUDLOH_OIDC_CLIENT_SECRET = "secret"; + testEnv.SUDLOH_OIDC_REDIRECT_URI = "https://guide.example.test/api/auth/callback/sudloh"; + testEnv.SUDLOH_OIDC_ONLY = "true"; + mocks.session.mockResolvedValue({ user: { id: "user-1", email: "a@test.invalid", + emailVerified: true, role: "admin" }, session: { id: "guide-session" } }); + sudloh.validate.mockResolvedValue(false); + const { getAdminSession, getCustomerSession } = await import("./server"); + expect(await getCustomerSession()).toBeNull(); + expect(await getAdminSession()).toBeNull(); }); it("rejects incomplete or mismatched Sudloh client configuration", async () => { const { getAuth, isSudlohOidcEnabled } = await import("./server"); diff --git a/lib/auth/server.ts b/lib/auth/server.ts index 3aa4032..885b3e9 100644 --- a/lib/auth/server.ts +++ b/lib/auth/server.ts @@ -19,6 +19,7 @@ import { isAuthorizedAdmin, type SessionUserLike } from "./authorization"; import { HttpError } from "@/lib/security/http"; import { consumeRateLimit } from "@/lib/security/rate-limit"; import { sendAuthEmail } from "./email"; +import { bindSudlohSession, validateSudlohSession } from "./sudloh"; function required(name: string): string { const value = process.env[name]; @@ -63,6 +64,11 @@ function createAuth() { }, transaction: true, }), + ...(oidcOnly ? { databaseHooks: { session: { create: { after: async ( + session: { id: string; userId: string }, context: { path: string } | null, + ) => { + if (context?.path.endsWith("/callback/sudloh")) await bindSudlohSession(session.userId, session.id); + } } } } } : {}), baseURL: required("BETTER_AUTH_URL"), trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS ?.split(",") @@ -125,6 +131,7 @@ function createAuth() { requireIdTokenVerification: true, requireEmailVerification: true, disableProviderLogout: true, + overrideUserInfo: true, }] })] : []), ...(!oidcOnly ? [emailOTP({ sendVerificationOnSignUp: true, @@ -159,13 +166,22 @@ export interface AdminSession { session: { id: string }; } +export const getVerifiedSession = cache(async () => { + if (!hasAuthConfiguration()) return null; + const session = await getAuth().api.getSession({ headers: await headers() }); + if (!session?.session) return null; + if (isSudlohOidcEnabled() && process.env.SUDLOH_OIDC_ONLY === "true" && + !await validateSudlohSession(session.user.id, session.session.id)) return null; + return session; +}); + export const getAdminSession = cache(async (): Promise => { // Public pages can be prerendered without the runtime auth secret. In that // case the header simply omits the admin link; auth routes still fail loudly // through getAuth() when authentication is actually used. if (!hasAuthConfiguration()) return null; - const session = await getAuth().api.getSession({ headers: await headers() }); + const session = await getVerifiedSession(); if ( !session?.session || !isAuthorizedAdmin(session.user) @@ -192,8 +208,7 @@ export async function requireAdmin(): Promise { } export const getCustomerSession = cache(async () => { - if (!hasAuthConfiguration()) return null; - const session = await getAuth().api.getSession({ headers: await headers() }); + const session = await getVerifiedSession(); if (!session?.session) return null; return { user: session.user, session: { id: session.session.id } }; }); diff --git a/lib/auth/sudloh.test.ts b/lib/auth/sudloh.test.ts new file mode 100644 index 0000000..ffd9e66 --- /dev/null +++ b/lib/auth/sudloh.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const rows: unknown[][] = []; +const values = new Map(); +const set = vi.fn(async (key: string, value: string) => { values.set(key, value); }); +const removeSession = vi.fn(async () => undefined); +const updateUser = vi.fn(async () => undefined); +const redis = { get: vi.fn(async (key: string) => values.get(key) ?? null), set }; + +vi.mock("server-only", () => ({})); +vi.mock("@/db", () => ({ getDb: () => ({ + select: () => ({ from: () => ({ where: () => ({ limit: async () => rows.shift() ?? [] }) }) }), + update: () => ({ set: () => ({ where: updateUser }) }), + delete: () => ({ where: removeSession }), +}) })); +vi.mock("@/lib/redis/client", () => ({ + getRedisClient: async () => redis, redisCachePrefix: () => "test", +})); + +const { bindSudlohSession, refreshLinkedSudlohProfile, validateSudlohSession } = await import("./sudloh"); +const expiresAt = new Date(Date.now() + 60 * 60_000); +const account = { id: "account-1", accountId: "sub-1", accessToken: "access-1", accessTokenExpiresAt: expiresAt }; + +beforeEach(() => { + rows.length = 0; + values.clear(); + vi.clearAllMocks(); + process.env.SUDLOH_OIDC_ISSUER = "https://account.test/api/auth"; + process.env.SUDLOH_OIDC_CLIENT_ID = "client"; + process.env.SUDLOH_OIDC_CLIENT_SECRET = "secret"; +}); + +function provider(active: boolean, profileSub = "sub-1") { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith("openid-configuration")) return Response.json({ + issuer: "https://account.test/api/auth", + introspection_endpoint: "https://account.test/api/auth/oauth2/introspect", + userinfo_endpoint: "https://account.test/api/auth/oauth2/userinfo", + }); + if (url.endsWith("introspect")) return Response.json({ active, sub: "sub-1", + exp: Math.floor(Date.now() / 1000) + 3600 }); + if (url.endsWith("userinfo")) return Response.json({ sub: profileSub, name: "New Name", + email: "new@test.invalid", email_verified: true, picture: "https://account.test/avatar.png" }); + throw new Error(`unexpected URL: ${url}`); + }); + return fetchMock; +} + +describe("Sudloh session validation", () => { + it("binds the callback token to its Guide session and caches a verified check", async () => { + rows.push([account]); + await bindSudlohSession("user-1", "session-1"); + const fetchMock = provider(true); + rows.push([account], [{ name: "Old Name", email: "old@test.invalid", emailVerified: true, image: null }]); + expect(await validateSudlohSession("user-1", "session-1")).toBe(true); + expect(updateUser).toHaveBeenCalledOnce(); + const fetchCount = fetchMock.mock.calls.length; + rows.push([account]); + expect(await validateSudlohSession("user-1", "session-1")).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(fetchCount); + fetchMock.mockRestore(); + }); + + it("ends the Guide session when Sudloh reports the bound token inactive", async () => { + rows.push([account]); + await bindSudlohSession("user-1", "session-1"); + const fetchMock = provider(false); + rows.push([account]); + expect(await validateSudlohSession("user-1", "session-1")).toBe(false); + expect(removeSession).toHaveBeenCalledOnce(); + fetchMock.mockRestore(); + }); + + it("fails closed when UserInfo returns another subject", async () => { + rows.push([account]); + await bindSudlohSession("user-1", "session-1"); + const fetchMock = provider(true, "someone-else"); + rows.push([account]); + await expect(validateSudlohSession("user-1", "session-1")) + .rejects.toMatchObject({ status: 503 }); + expect(updateUser).not.toHaveBeenCalled(); + fetchMock.mockRestore(); + }); + + it("reports a verified Sudloh email that conflicts with another Guide account", async () => { + rows.push([account]); + await bindSudlohSession("user-1", "session-1"); + const fetchMock = provider(true); + rows.push([account], [{ name: "Old Name", email: "old@test.invalid", emailVerified: true, image: null }]); + updateUser.mockRejectedValueOnce({ cause: { code: "23505" } }); + await expect(validateSudlohSession("user-1", "session-1")) + .rejects.toMatchObject({ status: 409, message: "sudloh-email-conflict" }); + fetchMock.mockRestore(); + }); + + it("refreshes a linked profile during the legacy sign-in transition", async () => { + const fetchMock = provider(true); + rows.push([account], [{ name: "Old Name", email: "old@test.invalid", emailVerified: true, image: null }]); + await refreshLinkedSudlohProfile("user-1"); + expect(updateUser).toHaveBeenCalledOnce(); + fetchMock.mockRestore(); + }); +}); diff --git a/lib/auth/sudloh.ts b/lib/auth/sudloh.ts new file mode 100644 index 0000000..11a9c0a --- /dev/null +++ b/lib/auth/sudloh.ts @@ -0,0 +1,164 @@ +import "server-only"; + +import { createHash } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { getDb } from "@/db"; +import { accounts, sessions, users } from "@/db/schema"; +import { getRedisClient, redisCachePrefix } from "@/lib/redis/client"; +import { HttpError } from "@/lib/security/http"; + +const CHECK_SECONDS = 300; +const ACCOUNT_SETTINGS_URL = "https://account.sudloh.com/account"; + +export { ACCOUNT_SETTINGS_URL }; + +type Endpoints = { issuer: string; userinfo_endpoint: string; introspection_endpoint: string }; +type BoundToken = { token: string; accountId: string; expiresAt: string }; + +let discovery: { endpoints: Endpoints; until: number } | undefined; + +async function endpoints(): Promise { + if (discovery && discovery.until > Date.now()) return discovery.endpoints; + const issuer = (process.env.SUDLOH_OIDC_ISSUER || "https://account.sudloh.com/api/auth").replace(/\/$/, ""); + const response = await fetch(`${issuer}/.well-known/openid-configuration`, { + cache: "no-store", signal: AbortSignal.timeout(5000), + }); + if (!response.ok) throw new HttpError(503, "sudloh-unavailable"); + const data: unknown = await response.json(); + if (!data || typeof data !== "object" || !("issuer" in data) || data.issuer !== issuer || + !("userinfo_endpoint" in data) || typeof data.userinfo_endpoint !== "string" || + !("introspection_endpoint" in data) || typeof data.introspection_endpoint !== "string") { + throw new HttpError(503, "sudloh-discovery-invalid"); + } + const result = data as Endpoints; + for (const url of [result.userinfo_endpoint, result.introspection_endpoint]) { + if (new URL(url).origin !== new URL(issuer).origin) throw new HttpError(503, "sudloh-discovery-invalid"); + } + discovery = { endpoints: result, until: Date.now() + 10 * 60_000 }; + return result; +} + +async function userInfo(account: BoundToken) { + const response = await fetch((await endpoints()).userinfo_endpoint, { + headers: { Authorization: `Bearer ${account.token}` }, + cache: "no-store", signal: AbortSignal.timeout(5000), + }); + if (!response.ok) throw new HttpError(503, "sudloh-unavailable"); + const data: unknown = await response.json(); + if (!data || typeof data !== "object" || + !("sub" in data) || data.sub !== account.accountId || + !("name" in data) || typeof data.name !== "string" || + !("email" in data) || typeof data.email !== "string" || + !("email_verified" in data) || data.email_verified !== true || + !("picture" in data) || typeof data.picture !== "string") { + throw new HttpError(503, "sudloh-profile-invalid"); + } + return data as { sub: string; name: string; email: string; email_verified: true; picture: string }; +} + +async function introspect(account: BoundToken): Promise { + const clientId = process.env.SUDLOH_OIDC_CLIENT_ID; + const clientSecret = process.env.SUDLOH_OIDC_CLIENT_SECRET; + if (!clientId || !clientSecret) throw new HttpError(503, "sudloh-not-configured"); + const response = await fetch((await endpoints()).introspection_endpoint, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ token: account.token, token_type_hint: "access_token" }), + cache: "no-store", signal: AbortSignal.timeout(5000), + }); + if (!response.ok) throw new HttpError(503, "sudloh-unavailable"); + const data: unknown = await response.json(); + if (!data || typeof data !== "object" || !("active" in data) || typeof data.active !== "boolean") { + throw new HttpError(503, "sudloh-introspection-invalid"); + } + if (!data.active) return false; + if (!("sub" in data) || data.sub !== account.accountId || + !("exp" in data) || typeof data.exp !== "number" || data.exp <= Date.now() / 1000) return false; + return true; +} + +function bindingKey(sessionId: string) { + return `${redisCachePrefix()}:sudloh:session:${sessionId}`; +} + +async function updateProfile(userId: string, token: BoundToken): Promise { + const profile = await userInfo(token); + const [current] = await getDb().select({ name: users.name, email: users.email, + emailVerified: users.emailVerified, image: users.image }).from(users).where(eq(users.id, userId)).limit(1); + if (!current) return false; + const email = profile.email.toLowerCase(); + if (current.name !== profile.name || current.email !== email || + !current.emailVerified || current.image !== profile.picture) { + try { + await getDb().update(users).set({ name: profile.name, email, + emailVerified: true, image: profile.picture }).where(eq(users.id, userId)); + } catch (cause) { + const dbError = cause && typeof cause === "object" && "cause" in cause ? cause.cause : cause; + if (dbError && typeof dbError === "object" && "code" in dbError && dbError.code === "23505") + throw new HttpError(409, "sudloh-email-conflict"); + throw cause; + } + } + return true; +} + +export async function refreshLinkedSudlohProfile(userId: string): Promise { + const [account] = await getDb().select().from(accounts).where(and( + eq(accounts.userId, userId), eq(accounts.providerId, "sudloh"), + )).limit(1); + if (!account?.accessToken || !account.accessTokenExpiresAt || + account.accessTokenExpiresAt.getTime() <= Date.now()) throw new HttpError(401, "sudloh-sign-in-required"); + const token: BoundToken = { token: account.accessToken, accountId: account.accountId, + expiresAt: account.accessTokenExpiresAt.toISOString() }; + if (!await introspect(token)) throw new HttpError(401, "sudloh-sign-in-required"); + if (!await updateProfile(userId, token)) throw new HttpError(401, "unauthorized"); +} + +export async function bindSudlohSession(userId: string, sessionId: string): Promise { + const [account] = await getDb().select().from(accounts).where(and( + eq(accounts.userId, userId), eq(accounts.providerId, "sudloh"), + )).limit(1); + if (!account?.accessToken || !account.accessTokenExpiresAt) throw new HttpError(503, "sudloh-token-missing"); + const seconds = Math.floor((account.accessTokenExpiresAt.getTime() - Date.now()) / 1000); + if (seconds <= 0) throw new HttpError(503, "sudloh-token-expired"); + const bound: BoundToken = { token: account.accessToken, accountId: account.accountId, + expiresAt: account.accessTokenExpiresAt.toISOString() }; + await (await getRedisClient()).set(bindingKey(sessionId), JSON.stringify(bound), "EX", seconds); +} + +export async function validateSudlohSession(userId: string, sessionId: string, force = false): Promise { + const [account] = await getDb().select({ id: accounts.id, accountId: accounts.accountId }) + .from(accounts).where(and(eq(accounts.userId, userId), eq(accounts.providerId, "sudloh"))).limit(1); + const invalidate = async () => { + await getDb().delete(sessions).where(eq(sessions.id, sessionId)); + return false; + }; + if (!account) return invalidate(); + const redis = await getRedisClient().catch(() => null); + if (!redis) throw new HttpError(503, "sudloh-check-unavailable"); + const raw = await redis.get(bindingKey(sessionId)).catch(() => { throw new HttpError(503, "sudloh-check-unavailable"); }); + if (!raw) return invalidate(); + let bound: BoundToken; + try { bound = JSON.parse(raw) as BoundToken; } + catch { throw new HttpError(503, "sudloh-check-unavailable"); } + if (bound.accountId !== account.accountId || !bound.token || + !Number.isFinite(Date.parse(bound.expiresAt)) || Date.parse(bound.expiresAt) <= Date.now()) return invalidate(); + + const digest = createHash("sha256").update(bound.token).digest("hex"); + const key = `${redisCachePrefix()}:sudloh:active:${sessionId}:${digest}`; + if (!force && await redis.get(key).catch(() => null) === "1") return true; + + try { + if (!await introspect(bound)) return invalidate(); + if (!await updateProfile(userId, bound)) return invalidate(); + const remaining = Math.floor((Date.parse(bound.expiresAt) - Date.now()) / 1000); + if (remaining > 0) await redis.set(key, "1", "EX", Math.min(CHECK_SECONDS, remaining)).catch(() => undefined); + return true; + } catch (cause) { + if (cause instanceof HttpError) throw cause; + throw new HttpError(503, "sudloh-unavailable"); + } +} diff --git a/lib/commission/server.ts b/lib/commission/server.ts index a8b26f8..1899ac0 100644 --- a/lib/commission/server.ts +++ b/lib/commission/server.ts @@ -1,7 +1,6 @@ import "server-only"; -import { headers } from "next/headers"; -import { getAuth } from "@/lib/auth/server"; +import { getVerifiedSession } from "@/lib/auth/server"; import { getDb } from "@/db"; import { users } from "@/db/schema"; import { eq } from "drizzle-orm"; @@ -9,7 +8,7 @@ import { HttpError } from "@/lib/security/http"; import { getRedisClient, redisEventChannel } from "@/lib/redis/client"; export async function requireCommissionUser() { - const session = await getAuth().api.getSession({ headers: await headers() }); + const session = await getVerifiedSession(); if (!session?.user) throw new HttpError(401, "unauthorized"); const [user] = await getDb().select().from(users).where(eq(users.id, session.user.id)).limit(1); if (!user || user.banned) throw new HttpError(401, "unauthorized");