UNPKG

@kilterset/auth0-actions-testing

Version:

Test and develop Auth0 Actions or Okta CIC Actions locally.

684 lines (558 loc) 21.5 kB
import test from "node:test"; import { strictEqual, deepStrictEqual, throws, ok } from "node:assert"; import { postLogin } from "../../mock/api"; import { request, user } from "../../mock"; import { encodeHS256JWT } from "../../jwt/hs256"; test("PostLogin API", async (t) => { await t.test("access", async (t) => { const { implementation: api, state } = postLogin(); strictEqual(api.access.deny("Only cool kids allowed"), api); deepStrictEqual(state.access.denied, { reason: "Only cool kids allowed" }); }); await t.test("accessToken", async (t) => { await t.test("is chainable", async (t) => { const { implementation: api, state } = postLogin(); strictEqual(api.accessToken.addScope("admin"), api); strictEqual(api.accessToken.setCustomClaim("favourite_pet", "cat"), api); }); await t.test("can add and remove scopes", async (t) => { const { implementation: api, state } = postLogin(); strictEqual(api.accessToken.addScope("admin"), api); api.accessToken.addScope("root"); deepStrictEqual(state.accessToken.scopes, ["admin", "root"]); api.accessToken.removeScope("root"); deepStrictEqual(state.accessToken.scopes, ["admin"]); }); await t.test("ignores duplicate scopes", async (t) => { const { implementation: api, state } = postLogin(); api.accessToken.addScope("duplicate"); api.accessToken.addScope("duplicate"); deepStrictEqual(state.accessToken.scopes, ["duplicate"]); }); await t.test("can set custom claims", async (t) => { const { implementation: api, state } = postLogin(); strictEqual(api.accessToken.setCustomClaim("favourite_pet", "cat"), api); deepStrictEqual(state.accessToken.claims, { favourite_pet: "cat" }); }); }); await t.test("set app metadata", async (t) => { const { implementation: api, state } = postLogin(); strictEqual(api.user.setAppMetadata("handle", "@pat"), api); deepStrictEqual(state.user.app_metadata, { handle: "@pat" }); }); await t.test("set user metadata", async (t) => { const { implementation: api, state } = postLogin(); strictEqual(api.user.setUserMetadata("favourite_pet", "cat"), api); deepStrictEqual(state.user.user_metadata, { favourite_pet: "cat" }); }); await t.test("can set ID token claims", async (t) => { const { implementation: api, state } = postLogin(); strictEqual(api.idToken.setCustomClaim("country", "NZ"), api); deepStrictEqual(state.idToken.claims, { country: "NZ" }); }); await t.test("enabling multifactor", async (t) => { const { implementation: api, state } = postLogin(); strictEqual(state.multifactor.enabled, false); const options = { allowRememberBrowser: true, providerOptions: { host: "custom-host", ikey: "custom-ikey", skey: "custom-skey", username: "custom-username", }, }; strictEqual(api.multifactor.enable("duo", options), api); deepStrictEqual(state.multifactor.enabled, { provider: "duo", options }); }); await t.test("can check whether a rule was executed", async (t) => { const { implementation: api, state } = postLogin({ executedRules: ["rule-42"], }); strictEqual(api.rules.wasExecuted("rule-42"), true); strictEqual(api.rules.wasExecuted("some-not-executed-rule"), false); }); await t.test("samlResponse customisation", async (t) => { const { implementation: api, state } = postLogin(); await t.test("attributes", async (t) => { deepStrictEqual(state.samlResponse.attributes, {}); api.samlResponse.setAttribute("species", "cat"); api.samlResponse.setAttribute("nickname", "Buddy"); deepStrictEqual(state.samlResponse.attributes, { species: "cat", nickname: "Buddy", }); }); await t.test("audience", async (t) => { strictEqual(state.samlResponse.audience, "default-audience"); api.samlResponse.setAudience("custom-audience"); strictEqual(state.samlResponse.audience, "custom-audience"); }); await t.test("recipient", async (t) => { strictEqual(state.samlResponse.recipient, "default-recipient"); api.samlResponse.setRecipient("custom-recipient"); strictEqual(state.samlResponse.recipient, "custom-recipient"); }); await t.test("destination", async (t) => { strictEqual(state.samlResponse.destination, "default-destination"); api.samlResponse.setDestination("custom-destination"); strictEqual(state.samlResponse.destination, "custom-destination"); }); await t.test("createUpnClaim", async (t) => { strictEqual(state.samlResponse.createUpnClaim, true); api.samlResponse.setCreateUpnClaim(false); strictEqual(state.samlResponse.createUpnClaim, false); }); await t.test("passthroughClaimsWithNoMapping", async (t) => { strictEqual(state.samlResponse.passthroughClaimsWithNoMapping, true); api.samlResponse.setPassthroughClaimsWithNoMapping(false); strictEqual(state.samlResponse.passthroughClaimsWithNoMapping, false); }); await t.test("mapUnknownClaimsAsIs", async (t) => { strictEqual(state.samlResponse.mapUnknownClaimsAsIs, false); api.samlResponse.setMapUnknownClaimsAsIs(true); strictEqual(state.samlResponse.mapUnknownClaimsAsIs, true); }); await t.test("mapIdentities", async (t) => { strictEqual(state.samlResponse.mapIdentities, true); api.samlResponse.setMapIdentities(false); strictEqual(state.samlResponse.mapIdentities, false); }); await t.test("signResponse", async (t) => { strictEqual(state.samlResponse.signResponse, false); api.samlResponse.setSignResponse(true); strictEqual(state.samlResponse.signResponse, true); }); await t.test("includeAttributeNameFormat", async (t) => { strictEqual(state.samlResponse.includeAttributeNameFormat, true); api.samlResponse.setIncludeAttributeNameFormat(false); strictEqual(state.samlResponse.includeAttributeNameFormat, false); }); await t.test("typedAttributes", async (t) => { strictEqual(state.samlResponse.typedAttributes, true); api.samlResponse.setTypedAttributes(false); strictEqual(state.samlResponse.typedAttributes, false); }); await t.test("lifetimeInSeconds", async (t) => { strictEqual(state.samlResponse.lifetimeInSeconds, 3600); api.samlResponse.setLifetimeInSeconds(42); strictEqual(state.samlResponse.lifetimeInSeconds, 42); }); await t.test("nameIdentifierFormat", async (t) => { const { implementation: api, state } = postLogin(); strictEqual( state.samlResponse.nameIdentifierFormat, "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" ); api.samlResponse.setNameIdentifierFormat("custom-format"); strictEqual(state.samlResponse.nameIdentifierFormat, "custom-format"); }); await t.test("nameIdentifierProbes", async (t) => { const { implementation: api, state } = postLogin(); deepStrictEqual(state.samlResponse.nameIdentifierProbes, [ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", ]); api.samlResponse.setNameIdentifierProbes([ "custom-probe-a", "custom-probe-b", ]); deepStrictEqual(state.samlResponse.nameIdentifierProbes, [ "custom-probe-a", "custom-probe-b", ]); }); await t.test("authnContextClassRef", async (t) => { const { implementation: api, state } = postLogin(); strictEqual( state.samlResponse.authnContextClassRef, "urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified" ); api.samlResponse.setAuthnContextClassRef( "custom-authn-context-class-ref" ); strictEqual( state.samlResponse.authnContextClassRef, "custom-authn-context-class-ref" ); }); }); await t.test("authentication", async (t) => { await t.test("challenge", async (t) => { await t.test("is false by default", async (t) => { const { implementation: api, state } = postLogin(); strictEqual(state.authentication.challenge, false); }); await t.test("can be set with a default", async (t) => { const { implementation: api, state } = postLogin(); strictEqual( api.authentication.challengeWith({ type: "otp" }), undefined ); deepStrictEqual(state.authentication.challenge, { default: { type: "otp" }, allOptions: [{ type: "otp" }], }); }); await t.test("can be set with a default and alternatives", async (t) => { const { implementation: api, state } = postLogin(); strictEqual( api.authentication.challengeWith( { type: "phone", options: { preferredMethod: "voice" }, }, { additionalFactors: [ { type: "email" }, { type: "webauthn-roaming" }, ], } ), undefined ); deepStrictEqual(state.authentication.challenge, { default: { type: "phone", options: { preferredMethod: "voice" }, }, allOptions: [ { type: "phone", options: { preferredMethod: "voice" }, }, { type: "email" }, { type: "webauthn-roaming" }, ], }); }); await t.test("can be set with options and no default", async (t) => { const { implementation: api, state } = postLogin(); const factors = [{ type: "email" }, { type: "webauthn-roaming" }]; strictEqual(api.authentication.challengeWithAny(factors), undefined); deepStrictEqual(state.authentication.challenge, { default: undefined, allOptions: factors, }); }); }); await t.test("enroll", async (t) => { await t.test("is false by default", async (t) => { const { implementation: api, state } = postLogin(); strictEqual(state.authentication.enrollment, false); }); await t.test("can be set with a default", async (t) => { const { implementation: api, state } = postLogin(); strictEqual(api.authentication.enrollWith({ type: "otp" }), undefined); deepStrictEqual(state.authentication.enrollment, { default: { type: "otp" }, allOptions: [{ type: "otp" }], }); }); await t.test("can be set with a default and alternatives", async (t) => { const { implementation: api, state } = postLogin(); strictEqual( api.authentication.enrollWith( { type: "phone", options: { preferredMethod: "voice" }, }, { additionalFactors: [ { type: "email" }, { type: "webauthn-roaming" }, ], } ), undefined ); deepStrictEqual(state.authentication.enrollment, { default: { type: "phone", options: { preferredMethod: "voice" }, }, allOptions: [ { type: "phone", options: { preferredMethod: "voice" }, }, { type: "email" }, { type: "webauthn-roaming" }, ], }); }); await t.test("can be set with options and no default", async (t) => { const { implementation: api, state } = postLogin(); const factors = [{ type: "email" }, { type: "webauthn-roaming" }]; strictEqual(api.authentication.enrollWithAny(factors), undefined); deepStrictEqual(state.authentication.enrollment, { default: undefined, allOptions: factors, }); }); }); await t.test( "recordMethod adds to the list of newly recorded methods", async (t) => { const { implementation: api, state } = postLogin(); strictEqual(api.authentication.recordMethod("https://method-1"), api); deepStrictEqual(state.authentication.newlyRecordedMethods, [ "https://method-1", ]); api.authentication.recordMethod("https://method-2"); deepStrictEqual(state.authentication.newlyRecordedMethods, [ "https://method-1", "https://method-2", ]); } ); await t.test("setPrimaryUser", async (t) => { await t.test("can change the primary user ID", async (t) => { const { implementation: api, state } = postLogin(); const originalUserId = state.user.user_id; strictEqual(state.authentication.primaryUserId, originalUserId); strictEqual( api.authentication.setPrimaryUser("new-primary-user-id"), undefined ); strictEqual(state.authentication.primaryUserId, "new-primary-user-id"); strictEqual(state.user.user_id, originalUserId); }); await t.test("can only be called once per transaction", async (t) => { const { implementation: api, state } = postLogin(); api.authentication.setPrimaryUser("new-primary-user-id"); throws( () => api.authentication.setPrimaryUser("new-primary-user-id"), /once/ ); }); }); }); await t.test("validation error", async (t) => { const { implementation: api, state } = postLogin(); strictEqual(state.validation.error, null); strictEqual(api.validation.error("E_KABOOM", "Something went wrong"), api); deepStrictEqual(state.validation.error, { code: "E_KABOOM", message: "Something went wrong", }); }); await t.test("redirect", async (t) => { await t.test("sendUserTo", async (t) => { await t.test("simple redirect", async (t) => { const { implementation: api, state } = postLogin(); strictEqual(api.redirect.sendUserTo("https://example.com/r"), api); const { redirect } = state; 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) => { const { implementation: api, state } = postLogin(); strictEqual( api.redirect.sendUserTo("https://example.com?bread=rye", { query: { filling: "cheese", spread: "butter" }, }), api ); const { redirect } = state; ok(redirect, "redirect not set"); deepStrictEqual( redirect.queryParams, { bread: "rye", filling: "cheese", spread: "butter" }, "unexpected query" ); strictEqual( redirect.url.href, "https://example.com/?bread=rye&filling=cheese&spread=butter", "url mismatch" ); }); }); await t.test("encodeToken", async (t) => { const now = new Date("2024-01-01T00:00:00.000Z"); const nowUnixTimestamp = now.getTime() / 1000; const { implementation: api } = postLogin({ request: request({ hostname: "example.com", ip: "d666:171e:e7a4:1aa3:359a:a317:9f53:ee97", }), user: user({ user_id: "auth0|8150" }), now, }); const token = api.redirect.encodeToken({ expiresInSeconds: 42, payload: { foo: "bar" }, secret: "shh", }); const [header, payload, signature] = token.split("."); const decodedHeader = JSON.parse(atob(header)); const decodedPayload = JSON.parse(atob(payload)); deepStrictEqual( decodedHeader, { alg: "HS256", typ: "JWT" }, "unexpected JWT header" ); deepStrictEqual( decodedPayload, { iss: "example.com", iat: nowUnixTimestamp, exp: nowUnixTimestamp + 42, sub: "auth0|8150", ip: "d666:171e:e7a4:1aa3:359a:a317:9f53:ee97", foo: "bar", }, "unexpected claims" ); strictEqual( signature, "ZlLKLk7uJzDjD0nt2a08QiWMY1EPnhFIuc8WsSZPBvQ", "invalid signature" ); }); await t.test("validateToken", async (t) => { const VALID_SECRET = "shh"; const TOKEN_PAYLOAD = { sub: "auth0|7321", iss: "myapp.com", iat: 1711509300, exp: 1711509800, state: "opaque-random-state", foo: "bar", }; const VALID_STATE = TOKEN_PAYLOAD.state; const VALID_TOKEN = encodeHS256JWT({ secret: VALID_SECRET, claims: TOKEN_PAYLOAD, }); const VALID_CONTEXT = { now: TOKEN_PAYLOAD.iat * 1000, user: user({ user_id: TOKEN_PAYLOAD.sub }), request: request({ method: "POST", body: { session_token: VALID_TOKEN, state: VALID_STATE, }, }), } as const; await t.test("decodes a valid POST token", async (t) => { const { now, user } = VALID_CONTEXT; const { implementation: api } = postLogin({ now, user, request: request({ method: "POST", body: { session_token: VALID_TOKEN, state: VALID_STATE, }, }), }); const payload = api.redirect.validateToken({ secret: VALID_SECRET }); deepStrictEqual(payload, TOKEN_PAYLOAD, "unexpected payload"); }); await t.test("decodes a valid GET token", async (t) => { const { now, user } = VALID_CONTEXT; const { implementation: api } = postLogin({ now, user, request: request({ method: "GET", query: { session_token: VALID_TOKEN, state: VALID_STATE, }, }), }); const payload = api.redirect.validateToken({ secret: VALID_SECRET }); deepStrictEqual(payload, TOKEN_PAYLOAD, "unexpected payload"); }); await t.test("can use an alternative parameter", async (t) => { const { now, user } = VALID_CONTEXT; const { implementation: api } = postLogin({ now, user, request: request({ method: "POST", body: { a_token_param: VALID_TOKEN, state: VALID_STATE, }, }), }); const payload = api.redirect.validateToken({ secret: VALID_SECRET, tokenParameterName: "a_token_param", }); deepStrictEqual(payload, TOKEN_PAYLOAD, "unexpected payload"); }); await t.test("throws error if state is mismatched", async (t) => { const context = structuredClone(VALID_CONTEXT); context.request.body.state = "mismatched-from-token-value"; const { implementation: api } = postLogin(context); throws( () => api.redirect.validateToken({ secret: VALID_SECRET }), /state in the token/i ); }); await t.test("throws error if param is missing", async (t) => { const { implementation: api } = postLogin(VALID_CONTEXT); throws( () => api.redirect.validateToken({ secret: VALID_SECRET, tokenParameterName: "a_token_param", }), /no parameter called 'a_token_param'/i ); }); await t.test("throws error if expired", async (t) => { const { user, request } = VALID_CONTEXT; const { implementation: api } = postLogin({ now: TOKEN_PAYLOAD.exp * 1000, // exactly the expiry time user, request, }); throws( () => api.redirect.validateToken({ secret: VALID_SECRET }), /expired/i ); }); await t.test("throws error if signature is invalid", async (t) => { const context = structuredClone(VALID_CONTEXT); context.request.body.session_token = VALID_TOKEN.replace( /\.[^.]+$/, ".badsignature" ); const { implementation: api } = postLogin(context); throws( () => api.redirect.validateToken({ secret: VALID_SECRET }), /signature/i ); }); for (const claim of ["sub", "iss", "iat", "exp"]) { await t.test(`throws error if ${claim} is missing`, async (t) => { const context = structuredClone(VALID_CONTEXT); const claims = structuredClone(TOKEN_PAYLOAD) as Record< string, unknown >; ok(claim in claims, "claim not in payload"); delete claims[claim]; context.request.body.session_token = encodeHS256JWT({ secret: VALID_SECRET, claims, }); const { implementation: api } = postLogin(context); throws( () => api.redirect.validateToken({ secret: VALID_SECRET }), /missing or invalid standard claims/i ); }); } }); }); });