From 0d874b2f89bb668d220b327c7f54be35f40f327a Mon Sep 17 00:00:00 2001 From: Pete Nicholls Date: Mon, 1 Jul 2024 16:16:36 +1200 Subject: [PATCH] Redirect API: make consistent PostLogin had `state.redirect.target` whereas PostChallenge had `state.redirect`. --- examples/Login/redirect.test.js | 8 +-- src/mock/api/post-challenge.ts | 103 ++++---------------------------- src/mock/api/post-login.ts | 8 +-- src/mock/api/redirect.ts | 13 +++- src/mock/api/user.ts | 6 +- src/test/api/post-login.test.ts | 20 ++----- src/test/api/redirect.test.ts | 61 +++++++++++++------ 7 files changed, 81 insertions(+), 138 deletions(-) diff --git a/examples/Login/redirect.test.js b/examples/Login/redirect.test.js index 39acf19..9ee6f91 100644 --- a/examples/Login/redirect.test.js +++ b/examples/Login/redirect.test.js @@ -21,26 +21,26 @@ test("redirect and continue with signed data", async (t) => { const { redirect } = action; strict( - redirect.target.queryParams.theme, + redirect.queryParams.theme, "spiffy", "Unexpected value for `theme` query parameter" ); strictEqual( // You can also use redirect.url.href to get the full URL as a string - redirect.target.url.origin, + redirect.url.origin, "https://example.com", "Unexpected redirect URL origin" ); strictEqual( - redirect.target.url.pathname, + redirect.url.pathname, "/sandwich-preferences", "Unexpected redirect URL path" ); // Test the signed JWT data payload - const { session_token } = redirect.target.queryParams; + const { session_token } = redirect.queryParams; const decoded = jwt.decodeJWTPayload(session_token); diff --git a/src/mock/api/post-challenge.ts b/src/mock/api/post-challenge.ts index 701055e..c69f166 100644 --- a/src/mock/api/post-challenge.ts +++ b/src/mock/api/post-challenge.ts @@ -2,8 +2,7 @@ import Auth0, { Factor } from "../../types"; import { cache as mockCache } from "./cache"; import { user as mockUser } from "../user"; import { request as mockRequest } from "../request"; -import { ok } from "node:assert"; -import { encodeHS256JWT, signHS256 } from "../../jwt/hs256"; +import { redirectMock } from "./redirect"; export interface PostChallengeOptions { user?: Auth0.User; @@ -38,8 +37,12 @@ export function postChallenge({ const apiCache = mockCache(cache); const userValue = user ?? mockUser(); const requestValue = request ?? mockRequest(); - const now = new Date(nowValue || Date.now()); + const redirect = redirectMock("PostChallenge", { + now, + request: requestValue, + user: userValue, + }); const state: PostChallengeState = { authentication: { @@ -51,7 +54,9 @@ export function postChallenge({ user: userValue, access: { denied: false }, cache: apiCache, - redirect: null, + get redirect() { + return redirect.state.target; + }, }; const api: Auth0.API.PostChallenge = { @@ -81,94 +86,8 @@ export function postChallenge({ cache: apiCache, - redirect: { - encodeToken: ({ expiresInSeconds, payload, secret }) => { - expiresInSeconds = expiresInSeconds ?? 900; - - const claims = { - iss: requestValue.hostname, - iat: Math.floor(now.getTime() / 1000), - exp: Math.floor((now.getTime() + expiresInSeconds * 1000) / 1000), - sub: userValue.user_id, - ip: requestValue.ip, - ...payload, - }; - - return encodeHS256JWT({ secret, claims }); - }, - - sendUserTo: (urlString, options) => { - const url = new URL(urlString); - - if (options?.query) { - for (const [key, value] of Object.entries(options.query)) { - url.searchParams.append(key, value); - } - } - - const queryParams = Object.fromEntries(url.searchParams.entries()); - - state.redirect = { url, queryParams }; - - return api; - }, - - validateToken: ({ tokenParameterName, secret }) => { - tokenParameterName = tokenParameterName ?? "session_token"; - const params = { ...requestValue.query, ...requestValue.body }; - - const tokenValue = params[tokenParameterName]; - - ok( - tokenParameterName in params, - `There is no parameter called '${tokenParameterName}' available in either the POST body or query string.` - ); - - const [rawHeader, rawClaims, signature] = String(tokenValue).split("."); - - const verify = (condition: boolean, message: string) => { - ok(condition, `The session token is invalid: ${message}`); - }; - - const [header, claims] = [rawHeader, rawClaims].map((part) => - JSON.parse(Buffer.from(part, "base64url").toString()) - ); - - verify( - claims.state === params.state, - "State in the token does not match the /continue state." - ); - - const expectedSignature = signHS256({ - secret, - body: `${rawHeader}.${rawClaims}`, - }); - - verify(signature === expectedSignature, "Failed signature validation"); - - const expectedClaims = ["sub", "iss", "exp", "iat"]; - - for (const claim of expectedClaims) { - verify(claim in claims, "Missing or invalid standard claims"); - } - - verify( - header.typ?.toUpperCase() === "JWT", - "Unexpected token payload type" - ); - - verify( - claims.sub === userValue.user_id, - "The sub claim does not match the user_id." - ); - - verify( - claims.exp > Math.floor(now.getTime() / 1000), - "Token has expired." - ); - - return claims as Record; - }, + get redirect() { + return redirect.build(api); }, }; diff --git a/src/mock/api/post-login.ts b/src/mock/api/post-login.ts index 6967167..808b520 100644 --- a/src/mock/api/post-login.ts +++ b/src/mock/api/post-login.ts @@ -80,9 +80,7 @@ export interface PostLoginState { validation: { error: { code: string; message: string } | null; }; - redirect: { - target: { url: URL; queryParams: Record } | null; - }; + redirect: { url: URL; queryParams: Record } | null; } export function postLogin({ @@ -125,7 +123,9 @@ export function postLogin({ multifactor: multifactor.state, samlResponse: samlResponse.state, validation: validation.state, - redirect: redirect.state, + get redirect() { + return redirect.state.target; + }, }; const api: Auth0.API.PostLogin = { diff --git a/src/mock/api/redirect.ts b/src/mock/api/redirect.ts index 36337c1..809c585 100644 --- a/src/mock/api/redirect.ts +++ b/src/mock/api/redirect.ts @@ -11,7 +11,7 @@ interface RedirectMockOptions { interface EncodeTokenOptions { expiresInSeconds?: number | undefined; payload: { - [key: string]: unknown; + [key: string]: unknown; }; secret: string; } @@ -25,7 +25,10 @@ interface ValidateTokenOptions { secret: string; } -export function redirectMock(flow: string, { now: nowValue, request, user }: RedirectMockOptions) { +export function redirectMock( + flow: string, + { now: nowValue, request, user }: RedirectMockOptions +) { const now = new Date(nowValue || Date.now()); const state = { @@ -33,7 +36,11 @@ export function redirectMock(flow: string, { now: nowValue, request, user }: Red }; const build = (api: T) => ({ - encodeToken: ({ expiresInSeconds, payload, secret }: EncodeTokenOptions) => { + encodeToken: ({ + expiresInSeconds, + payload, + secret, + }: EncodeTokenOptions) => { expiresInSeconds = expiresInSeconds ?? 900; const claims = { diff --git a/src/mock/api/user.ts b/src/mock/api/user.ts index 8637889..a2ed973 100644 --- a/src/mock/api/user.ts +++ b/src/mock/api/user.ts @@ -1,7 +1,7 @@ import { User } from "../../types"; -export function userMock(flow: string, { user }: { user: User}) { - const state = user +export function userMock(flow: string, { user }: { user: User }) { + const state = user; const build = (api: T) => ({ setAppMetadata: (key: string, value: unknown) => { @@ -16,5 +16,5 @@ export function userMock(flow: string, { user }: { user: User}) { }, }); - return { build, state } + return { build, state }; } diff --git a/src/test/api/post-login.test.ts b/src/test/api/post-login.test.ts index 4b5c548..b79d89c 100644 --- a/src/test/api/post-login.test.ts +++ b/src/test/api/post-login.test.ts @@ -421,17 +421,9 @@ test("PostLogin API", async (t) => { const { redirect } = state; - ok(redirect.target, "redirect not set"); - deepStrictEqual( - redirect.target.queryParams, - {}, - "query should be empty" - ); - strictEqual( - redirect.target.url.href, - "https://example.com/r", - "url mismatch" - ); + ok(redirect, "redirect not set"); + deepStrictEqual(redirect.queryParams, {}, "query should be empty"); + strictEqual(redirect.url.href, "https://example.com/r", "url mismatch"); }); await t.test("redirect with consolidated GET parameters", async (t) => { @@ -446,16 +438,16 @@ test("PostLogin API", async (t) => { const { redirect } = state; - ok(redirect.target, "redirect not set"); + ok(redirect, "redirect not set"); deepStrictEqual( - redirect.target.queryParams, + redirect.queryParams, { bread: "rye", filling: "cheese", spread: "butter" }, "unexpected query" ); strictEqual( - redirect.target.url.href, + redirect.url.href, "https://example.com/?bread=rye&filling=cheese&spread=butter", "url mismatch" ); diff --git a/src/test/api/redirect.test.ts b/src/test/api/redirect.test.ts index 088c659..068914d 100644 --- a/src/test/api/redirect.test.ts +++ b/src/test/api/redirect.test.ts @@ -15,7 +15,11 @@ test("redirect", async (t) => { ip: "d666:171e:e7a4:1aa3:359a:a317:9f53:ee97", }); const user = mockUser({ user_id: "auth0|8150" }); - const { build, state } = redirectMock("Another Flow", { now, request, user }); + const { build, state } = redirectMock("Another Flow", { + now, + request, + user, + }); const api = build(baseApi); const token = api.encodeToken({ @@ -52,7 +56,7 @@ test("redirect", async (t) => { signature, "ZlLKLk7uJzDjD0nt2a08QiWMY1EPnhFIuc8WsSZPBvQ", "invalid signature" - ); + ); }); await t.test("sendUserTo", async (t) => { @@ -60,21 +64,33 @@ test("redirect", async (t) => { const now = new Date(); const request = mockRequest(); const user = mockUser(); - const { build, state } = redirectMock("Another Flow", { now, request, user }); + const { build, state } = redirectMock("Another Flow", { + now, + request, + user, + }); const api = build(baseApi); - strictEqual(api.sendUserTo("https://example.com/r"), baseApi, ); + strictEqual(api.sendUserTo("https://example.com/r"), baseApi); ok(state.target, "redirect not set"); deepStrictEqual(state.target.queryParams, {}, "query should be empty"); - strictEqual(state.target.url.href, "https://example.com/r", "url mismatch"); + strictEqual( + state.target.url.href, + "https://example.com/r", + "url mismatch" + ); }); await t.test("redirect with consolidated GET parameters", async (t) => { const now = new Date(); const request = mockRequest(); const user = mockUser(); - const { build, state } = redirectMock("Another Flow", { now, request, user }); + const { build, state } = redirectMock("Another Flow", { + now, + request, + user, + }); const api = build(baseApi); strictEqual( @@ -140,7 +156,11 @@ test("redirect", async (t) => { state: VALID_STATE, }, }); - const { build, state } = redirectMock("Another Flow", { now, request, user }); + const { build, state } = redirectMock("Another Flow", { + now, + request, + user, + }); const api = build(baseApi); const payload = api.validateToken({ secret: VALID_SECRET }); @@ -157,7 +177,11 @@ test("redirect", async (t) => { state: VALID_STATE, }, }); - const { build, state } = redirectMock("Another Flow", { now, request, user }); + const { build, state } = redirectMock("Another Flow", { + now, + request, + user, + }); const api = build(baseApi); const payload = api.validateToken({ secret: VALID_SECRET }); @@ -174,7 +198,11 @@ test("redirect", async (t) => { state: VALID_STATE, }, }); - const { build, state } = redirectMock("Another Flow", { now, request, user }); + const { build, state } = redirectMock("Another Flow", { + now, + request, + user, + }); const api = build(baseApi); const payload = api.validateToken({ @@ -214,13 +242,13 @@ test("redirect", async (t) => { await t.test("throws error if expired", async (t) => { const { user, request } = VALID_CONTEXT; const now = TOKEN_PAYLOAD.exp * 1000; // exactly the expiry time - const { build, state } = redirectMock("Another Flow", { ...VALID_CONTEXT, now }); + const { build, state } = redirectMock("Another Flow", { + ...VALID_CONTEXT, + now, + }); const api = build(baseApi); - throws( - () => api.validateToken({ secret: VALID_SECRET }), - /expired/i - ); + throws(() => api.validateToken({ secret: VALID_SECRET }), /expired/i); }); await t.test("throws error if signature is invalid", async (t) => { @@ -232,10 +260,7 @@ test("redirect", async (t) => { const { build, state } = redirectMock("Another Flow", context); const api = build(baseApi); - throws( - () => api.validateToken({ secret: VALID_SECRET }), - /signature/i - ); + throws(() => api.validateToken({ secret: VALID_SECRET }), /signature/i); }); for (const claim of ["sub", "iss", "iat", "exp"]) {