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
8 changes: 8 additions & 0 deletions .changeset/silent-refresh-auth-server-issuer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@seamless-auth/core": patch
"@seamless-auth/express": patch
"@seamless-auth/fastify": patch
"@seamless-auth/nextjs": patch
---

Check a silently refreshed access token against `authServerIssuer`. The silent refresh in `ensureCookies` now verifies the token it returns, but it checked `iss` against `authServerUrl` even when `authServerIssuer` was set, so an app reaching the auth server at another URL (the local Docker stack from the host) answered 401 on every silent refresh and signed the user out. `EnsureCookiesOptions` and the Express `createEnsureCookiesMiddleware` take an optional `authServerIssuer`, and the adapters pass their configured one.
4 changes: 3 additions & 1 deletion packages/core/src/ensureCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { verifyCookieJwt } from "./verifyCookieJwt.js";
import type { ResultFailure } from "./result.js";
import { refreshAccessToken } from "./refreshAccessToken.js";
import { assertSecrets } from "./validateSecrets.js";
import type { AuthServerIssuerOption } from "./authServerIssuer.js";
import {
issueSessionCookies,
type UpstreamSessionResponse,
Expand Down Expand Up @@ -43,7 +44,7 @@ export interface EnsureCookiesResult extends ResultFailure {
clearCookies?: string[];
}

export interface EnsureCookiesOptions {
export interface EnsureCookiesOptions extends AuthServerIssuerOption {
authServerUrl: string;
cookieDomain?: string;
accessCookieName: string;
Expand Down Expand Up @@ -271,6 +272,7 @@ async function refreshRequiredCookie(
{
authServerUrl: opts.authServerUrl,
audience: opts.accessTokenAudience || opts.authServerUrl,
authServerIssuer: opts.authServerIssuer,
accessCookieName: cookieName,
refreshCookieName: opts.refreshCookieName,
cookieDomain: opts.cookieDomain,
Expand Down
20 changes: 20 additions & 0 deletions packages/core/tests/ensureCookies.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ describe("ensureCookies", () => {
"new-access",
"https://auth.example.com",
"https://app.example.com",
undefined,
);
});

Expand All @@ -306,6 +307,25 @@ describe("ensureCookies", () => {
"new-access",
"https://auth.example.com",
"https://auth.example.com",
undefined,
);
});

it("verifies against the configured auth server issuer", async () => {
verifySignedAuthResponseMock.mockResolvedValue({ sub: "user-123" });

await silentRefresh({
...BASE_OPTS,
authServerUrl: "http://localhost:5312",
authServerIssuer: "http://auth:5312",
accessTokenAudience: "http://auth:5312",
});

expect(verifySignedAuthResponseMock).toHaveBeenCalledWith(
"new-access",
"http://localhost:5312",
"http://auth:5312",
"http://auth:5312",
);
});

Expand Down
1 change: 1 addition & 0 deletions packages/express/src/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ export function createSeamlessAuthServer(
issuer: SERVICE_TOKEN_ISSUER,
audience: SERVICE_TOKEN_AUDIENCE,
accessTokenAudience: resolvedOpts.audience,
authServerIssuer: resolvedOpts.authServerIssuer,
keyId: resolvedOpts.jwksKid,
resolveClientIp: resolvedOpts.resolveClientIp,
}),
Expand Down
7 changes: 7 additions & 0 deletions packages/express/src/middleware/ensureCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export interface EnsureCookiesMiddlewareOptions {
* refreshed token is verified against. Defaults to `authServerUrl`.
*/
accessTokenAudience?: string;
/**
* Expected `iss` on a silently refreshed access token, when the auth server
* signs under a different issuer from `authServerUrl`. Defaults to
* `authServerUrl`.
*/
authServerIssuer?: string;
keyId: string;
resolveClientIp?: ClientIpResolver;
}
Expand Down Expand Up @@ -68,6 +74,7 @@ export function createEnsureCookiesMiddleware(
issuer: opts.issuer,
audience: opts.audience,
accessTokenAudience: opts.accessTokenAudience,
authServerIssuer: opts.authServerIssuer,
keyId: opts.keyId,
forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp),
forwardedUserAgent: buildForwardedUserAgent(req),
Expand Down
32 changes: 30 additions & 2 deletions packages/express/tests/ensureCookies.middleware.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ describe("createEnsureCookiesMiddleware silent refresh", () => {
return server;
}

async function refreshWithTokenFor(audience, options, refreshToken) {
async function refreshWithTokenFor(audience, options, refreshToken, issuer = AUTH) {
const token = jwt.sign({ sub: "user-123", typ: "access", sid: "s-1" }, privateKey, {
algorithm: "RS256",
keyid: "k1",
issuer: AUTH,
issuer,
audience,
expiresIn: "5m",
});
Expand Down Expand Up @@ -131,4 +131,32 @@ describe("createEnsureCookiesMiddleware silent refresh", () => {
expect(res.status).toBe(401);
expect(res.headers["set-cookie"].join(";")).not.toMatch(/access=ey/);
});

// On the local Docker stack the auth server signs as http://auth:5312 while
// the app reaches it at authServerUrl (fells-code/seamless-cli#224).
it("verifies the refreshed token against authServerIssuer", async () => {
const res = await refreshWithTokenFor(
"http://auth:5312",
{ accessTokenAudience: "http://auth:5312", authServerIssuer: "http://auth:5312" },
"opaque-3",
"http://auth:5312",
);

expect(res.status).toBe(200);
expect(res.body).toMatchObject({ sub: "user-123", sessionId: "s-1" });
});

it("refuses a refreshed token from an issuer other than the expected one", async () => {
const spy = jest.spyOn(console, "error").mockImplementation(() => {});
const res = await refreshWithTokenFor(
"http://auth:5312",
{ accessTokenAudience: "http://auth:5312" },
"opaque-4",
"http://auth:5312",
);
spy.mockRestore();

expect(res.status).toBe(401);
expect(res.headers["set-cookie"].join(";")).not.toMatch(/access=ey/);
});
});
1 change: 1 addition & 0 deletions packages/fastify/src/hooks/ensureCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export function createEnsureCookiesHook(opts: ResolvedOptions, prefix: string) {
issuer: SERVICE_TOKEN_ISSUER,
audience: SERVICE_TOKEN_AUDIENCE,
accessTokenAudience: opts.audience,
authServerIssuer: opts.authServerIssuer,
keyId: opts.jwksKid,
forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp),
forwardedUserAgent: buildForwardedUserAgent(req),
Expand Down
52 changes: 52 additions & 0 deletions packages/fastify/tests/issuer.parity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ async function mockUpstream(issuer) {
refreshTtl: 2592000,
});
}
if (href === `${AUTH}/refresh`) {
return Response.json({
sub: "user-123",
token: access,
refreshToken: "refresh-2",
roles: ["athlete"],
ttl: 1800,
refreshTtl: 2592000,
});
}
if (href === `${AUTH}/users/me`) {
return Response.json({ user: { id: "user-123" } });
}
throw new Error(`Unexpected upstream call: ${href}`);
});

Expand All @@ -106,6 +119,19 @@ const preAuthCookie = () =>
{ algorithm: "HS256", expiresIn: "300s" },
)}`;

// Each silent refresh needs its own refresh token: core replays a recent
// refresh result for the same token rather than calling the auth API again.
let refreshCount = 0;
const silentRefresh = () => ({
method: "get",
path: "/users/me",
cookie: `seamless-refresh=${jwt.sign(
{ sub: "user-123", refreshToken: `refresh-${++refreshCount}` },
COOKIE_SECRET,
{ algorithm: "HS256", expiresIn: "3600s" },
)}`,
});

const DOCKER_OPTIONS = { authServerIssuer: DOCKER_ISSUER, audience: DOCKER_ISSUER };

const STEPS = [
Expand Down Expand Up @@ -243,6 +269,32 @@ describe("authServerIssuer", () => {
}
},
);

it("accepts a silent refresh signed by a configured issuer distinct from the URL", async () => {
const calls = await mockUpstream(DOCKER_ISSUER);

const res = await run(silentRefresh(), DOCKER_OPTIONS);

expect(res.status).toBe(200);
expect(res.cookies).toEqual(
expect.arrayContaining(["seamless-access", "seamless-refresh"]),
);
expect(calls.every((href) => href.startsWith(`${AUTH}/`))).toBe(true);
});

it("rejects a silent refresh from an issuer other than the expected one", async () => {
await mockUpstream(DOCKER_ISSUER);
const unconfigured = await run(silentRefresh(), {});

await mockUpstream(AUTH);
const misconfigured = await run(silentRefresh(), DOCKER_OPTIONS);

// The 401 clears the session cookies, so their names still appear.
for (const res of [unconfigured, misconfigured]) {
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "Refresh failed" });
}
});
});

it("sets the same session cookies from both adapters with a configured issuer", async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ async function dispatch(
issuer: SERVICE_TOKEN_ISSUER,
audience: SERVICE_TOKEN_AUDIENCE,
accessTokenAudience: opts.audience,
authServerIssuer: opts.authServerIssuer,
keyId: opts.jwksKid,
forwardedClientIp: forwardedClientIp(request, opts.resolveClientIp),
forwardedUserAgent: forwardedUserAgent(request),
Expand Down
52 changes: 52 additions & 0 deletions packages/nextjs/tests/issuer.parity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ async function mockUpstream(issuer) {
refreshTtl: 2592000,
});
}
if (href === `${AUTH}/refresh`) {
return Response.json({
sub: "user-123",
token: access,
refreshToken: "refresh-2",
roles: ["athlete"],
ttl: 1800,
refreshTtl: 2592000,
});
}
if (href === `${AUTH}/users/me`) {
return Response.json({ user: { id: "user-123" } });
}
throw new Error(`Unexpected upstream call: ${href}`);
});

Expand All @@ -106,6 +119,19 @@ const preAuthCookie = () =>
{ algorithm: "HS256", expiresIn: "300s" },
)}`;

// Each silent refresh needs its own refresh token: core replays a recent
// refresh result for the same token rather than calling the auth API again.
let refreshCount = 0;
const silentRefresh = () => ({
method: "get",
path: "/users/me",
cookie: `seamless-refresh=${jwt.sign(
{ sub: "user-123", refreshToken: `refresh-${++refreshCount}` },
COOKIE_SECRET,
{ algorithm: "HS256", expiresIn: "3600s" },
)}`,
});

const DOCKER_OPTIONS = { authServerIssuer: DOCKER_ISSUER, audience: DOCKER_ISSUER };

const STEPS = [
Expand Down Expand Up @@ -243,6 +269,32 @@ describe("authServerIssuer", () => {
}
},
);

it("accepts a silent refresh signed by a configured issuer distinct from the URL", async () => {
const calls = await mockUpstream(DOCKER_ISSUER);

const res = await run(silentRefresh(), DOCKER_OPTIONS);

expect(res.status).toBe(200);
expect(res.cookies).toEqual(
expect.arrayContaining(["seamless-access", "seamless-refresh"]),
);
expect(calls.every((href) => href.startsWith(`${AUTH}/`))).toBe(true);
});

it("rejects a silent refresh from an issuer other than the expected one", async () => {
await mockUpstream(DOCKER_ISSUER);
const unconfigured = await run(silentRefresh(), {});

await mockUpstream(AUTH);
const misconfigured = await run(silentRefresh(), DOCKER_OPTIONS);

// The 401 clears the session cookies, so their names still appear.
for (const res of [unconfigured, misconfigured]) {
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "Refresh failed" });
}
});
});

it("sets the same session cookies from both adapters with a configured issuer", async () => {
Expand Down
Loading