Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make redirect state consistent between APIs #29

Merged
merged 1 commit into from
Jul 1, 2024
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: 4 additions & 4 deletions examples/Login/redirect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
103 changes: 11 additions & 92 deletions src/mock/api/post-challenge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: {
Expand All @@ -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 = {
Expand Down Expand Up @@ -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<string, unknown>;
},
get redirect() {
return redirect.build(api);
},
};

Expand Down
8 changes: 4 additions & 4 deletions src/mock/api/post-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,7 @@ export interface PostLoginState {
validation: {
error: { code: string; message: string } | null;
};
redirect: {
target: { url: URL; queryParams: Record<string, string> } | null;
};
redirect: { url: URL; queryParams: Record<string, string> } | null;
}

export function postLogin({
Expand Down Expand Up @@ -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 = {
Expand Down
13 changes: 10 additions & 3 deletions src/mock/api/redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ interface RedirectMockOptions {
interface EncodeTokenOptions {
expiresInSeconds?: number | undefined;
payload: {
[key: string]: unknown;
[key: string]: unknown;
};
secret: string;
}
Expand All @@ -25,15 +25,22 @@ 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 = {
target: null as null | { url: URL; queryParams: Record<string, string> },
};

const build = <T>(api: T) => ({
encodeToken: ({ expiresInSeconds, payload, secret }: EncodeTokenOptions) => {
encodeToken: ({
expiresInSeconds,
payload,
secret,
}: EncodeTokenOptions) => {
expiresInSeconds = expiresInSeconds ?? 900;

const claims = {
Expand Down
6 changes: 3 additions & 3 deletions src/mock/api/user.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(api: T) => ({
setAppMetadata: (key: string, value: unknown) => {
Expand All @@ -16,5 +16,5 @@ export function userMock(flow: string, { user }: { user: User}) {
},
});

return { build, state }
return { build, state };
}
20 changes: 6 additions & 14 deletions src/test/api/post-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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"
);
Expand Down
Loading
Loading