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"
"เพิ่มรูปโปรไฟล์หรือแก้ชื่อที่แสดงก่อนเริ่มใช้งาน"}