diff --git a/packages/api/src/controllers/token.test.ts b/packages/api/src/controllers/token.test.ts index f68fff01..23e7a79e 100644 --- a/packages/api/src/controllers/token.test.ts +++ b/packages/api/src/controllers/token.test.ts @@ -166,6 +166,10 @@ test("assertRefreshTokenClientBinding allows unbound first-party cookie refresh" assert.doesNotThrow(() => assertRefreshTokenClientBinding(null, "demo-client", false)); }); +test("assertRefreshTokenClientBinding allows bound first-party cookie refresh for another client", () => { + assert.doesNotThrow(() => assertRefreshTokenClientBinding("user", "demo-client", false)); +}); + test("shouldIssueFirstPartyRefreshCookies returns true for cookie-transport refresh requests", () => { assert.equal( shouldIssueFirstPartyRefreshCookies({ diff --git a/packages/api/src/controllers/user/oauthEndpoints.test.ts b/packages/api/src/controllers/user/oauthEndpoints.test.ts index d8894e36..a5601701 100644 --- a/packages/api/src/controllers/user/oauthEndpoints.test.ts +++ b/packages/api/src/controllers/user/oauthEndpoints.test.ts @@ -427,6 +427,43 @@ test("token allows unbound first-party cookie refresh", async () => { } }); +test("token allows hosted first-party cookie refresh for public SPA clients", async () => { + const { context, cleanup } = await createContext(); + try { + await createUser(context); + await createUserOrganization(context); + await createPublicRefreshClient(context); + const issued = await createSession(context, "user", { + sub: "user-sub", + clientId: "user", + scope: "darkauth.users:read", + }); + const request = createRequest({ + method: "POST", + url: "/token", + cookie: `${USER_REFRESH_COOKIE_NAME}=${encodeURIComponent(issued.refreshToken)}`, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: "public-refresh-client", + }).toString(), + }); + const response = createResponse(); + + await postToken(context, request, response); + + const json = response.json as Record; + const setCookie = response.getHeader("set-cookie"); + assert.equal(response.statusCode, 200); + assert.equal(json.token_type, "Bearer"); + assert.equal(json.scope, "openid profile"); + assert.equal(json.refresh_token, undefined); + assert.ok(Array.isArray(setCookie)); + assert.ok(setCookie.some((value) => value.includes(USER_REFRESH_COOKIE_NAME))); + } finally { + await cleanup(); + } +}); + test("introspect returns active metadata for same-client access tokens", async () => { const { context, cleanup } = await createContext(); try { diff --git a/packages/api/src/controllers/user/token.ts b/packages/api/src/controllers/user/token.ts index a5c6c67d..46f2d077 100644 --- a/packages/api/src/controllers/user/token.ts +++ b/packages/api/src/controllers/user/token.ts @@ -181,6 +181,7 @@ export function assertRefreshTokenClientBinding( } return; } + if (!requireIssuedClientId) return; if (issuedClientId !== authenticatedClientId) { throw new InvalidGrantError("Refresh token was not issued to this client"); } @@ -378,11 +379,14 @@ export const postToken = withRateLimit("token")( : 300; let amr: string[] | undefined = ["pwd"]; const data = sessionData as Record; - if (data && data.otpVerified === true) amr = ["pwd", "otp"]; + if (data.otpVerified === true) amr = ["pwd", "otp"]; + const shouldReuseSessionScope = issuedClientId === providedClientId; const grantedScope = - typeof data.scope === "string" && data.scope.length > 0 + shouldReuseSessionScope && typeof data.scope === "string" && data.scope.length > 0 ? data.scope - : resolveGrantedScopes(resolveClientScopeKeys(client.scopes)).join(" "); + : resolveGrantedScopes(resolveClientScopeKeys(client.scopes), tokenRequest.scope).join( + " " + ); const grantedScopes = parseScopeString(grantedScope); const delegatedPermissions = resolveDelegatedPermissions(uniquePermissions, grantedScopes); const issuer = await resolveIssuer(context); diff --git a/packages/api/src/http/createServer.test.ts b/packages/api/src/http/createServer.test.ts new file mode 100644 index 00000000..c0cd89bd --- /dev/null +++ b/packages/api/src/http/createServer.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { isUserCorsOriginAllowed, type UserCorsPolicy } from "./createServer.ts"; + +function policy(): UserCorsPolicy { + return { + cachedAt: Date.now(), + firstPartyOrigins: new Set(["https://my.wylde.net"]), + publicSpaOrigins: new Set(["https://atlas.wylde.net"]), + }; +} + +test("user CORS allows SDK user endpoints for registered public SPA origins", () => { + const corsPolicy = policy(); + + assert.equal( + isUserCorsOriginAllowed("/api/user/organizations", "https://atlas.wylde.net", corsPolicy), + true + ); + assert.equal( + isUserCorsOriginAllowed("/api/user/session", "https://atlas.wylde.net", corsPolicy), + true + ); +}); + +test("user CORS rejects SDK user endpoints for unregistered origins", () => { + assert.equal( + isUserCorsOriginAllowed("/api/user/organizations", "https://evil.example", policy()), + false + ); +}); diff --git a/packages/api/src/http/createServer.ts b/packages/api/src/http/createServer.ts index 0d0a2a64..e31cac9a 100644 --- a/packages/api/src/http/createServer.ts +++ b/packages/api/src/http/createServer.ts @@ -28,7 +28,7 @@ import { createUserRouter } from "./routers/userRouter.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -type UserCorsPolicy = { +export type UserCorsPolicy = { cachedAt: number; firstPartyOrigins: Set; publicSpaOrigins: Set; @@ -86,7 +86,20 @@ async function buildUserCorsPolicy(context: Context): Promise { }; } -function isUserCorsOriginAllowed( +function isPublicSpaCorsPath(pathname: string): boolean { + return ( + pathname === "/token" || + pathname === "/api/token" || + pathname === "/userinfo" || + pathname === "/api/userinfo" || + pathname === "/revoke" || + pathname === "/api/revoke" || + pathname === "/api/user/organizations" || + pathname === "/api/user/session" + ); +} + +export function isUserCorsOriginAllowed( pathname: string, origin: string, policy: UserCorsPolicy @@ -97,14 +110,7 @@ function isUserCorsOriginAllowed( pathname === "/api/.well-known/openid-configuration" ) return true; - if ( - pathname === "/token" || - pathname === "/api/token" || - pathname === "/userinfo" || - pathname === "/api/userinfo" || - pathname === "/revoke" || - pathname === "/api/revoke" - ) { + if (isPublicSpaCorsPath(pathname)) { return policy.publicSpaOrigins.has(origin); } return false; diff --git a/packages/test-suite/tests/demo/demo-app-note-flow.spec.ts b/packages/test-suite/tests/demo/demo-app-note-flow.spec.ts index 899cb8ea..923156b2 100644 --- a/packages/test-suite/tests/demo/demo-app-note-flow.spec.ts +++ b/packages/test-suite/tests/demo/demo-app-note-flow.spec.ts @@ -84,6 +84,7 @@ test.describe('Demo App Note Flow', () => { await adminPage.close(); const userData = await registerDemoUser(context, servers); await userData.page.close(); + await context.clearCookies({ name: '__Host-DarkAuth-User-Refresh' }); const snapshot = userData.snapshot; const denyPage = await context.newPage();