Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/api/src/controllers/token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
37 changes: 37 additions & 0 deletions packages/api/src/controllers/user/oauthEndpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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 {
Expand Down
10 changes: 7 additions & 3 deletions packages/api/src/controllers/user/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down Expand Up @@ -378,11 +379,14 @@ export const postToken = withRateLimit("token")(
: 300;
let amr: string[] | undefined = ["pwd"];
const data = sessionData as Record<string, unknown>;
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);
Expand Down
31 changes: 31 additions & 0 deletions packages/api/src/http/createServer.test.ts
Original file line number Diff line number Diff line change
@@ -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
);
});
26 changes: 16 additions & 10 deletions packages/api/src/http/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
publicSpaOrigins: Set<string>;
Expand Down Expand Up @@ -86,7 +86,20 @@ async function buildUserCorsPolicy(context: Context): Promise<UserCorsPolicy> {
};
}

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
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/test-suite/tests/demo/demo-app-note-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading