@auth0/nextjs-auth0
Version:
Auth0 Next.js SDK
1,049 lines (1,048 loc) • 243 kB
JavaScript
import { NextRequest, NextResponse } from "next/server.js";
import * as jose from "jose";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { getDefaultRoutes } from "../test/defaults.js";
import { generateSecret } from "../test/utils.js";
import { SUBJECT_TOKEN_TYPES } from "../types/index.js";
import { AuthClient } from "./auth-client.js";
import { decrypt, encrypt } from "./cookies.js";
import { StatefulSessionStore } from "./session/stateful-session-store.js";
import { StatelessSessionStore } from "./session/stateless-session-store.js";
import { TransactionStore } from "./transaction-store.js";
describe("Authentication Client", async () => {
const DEFAULT = {
domain: "guabu.us.auth0.com",
clientId: "client_123",
clientSecret: "client-secret",
appBaseUrl: "https://example.com",
sid: "auth0-sid",
idToken: "idt_123",
accessToken: "at_123",
refreshToken: "rt_123",
sub: "user_123",
alg: "RS256",
keyPair: await jose.generateKeyPair("RS256"),
clientAssertionSigningKey: `-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDbTKOQLtaZ6U1k
3fcYCMVoy8poieNPPcbj15TCLOm4Bbox73/UUxIArqczVcjtUGnL+jn5982V5EiB
y8W51m5K9mIBgEFLYdLkXk+OW5UTE/AdMPtfsIjConGrrs3mxN4WSH9kvh9Yr41r
hWUUSwqFyMOssbGE8K46Cv0WYvS7RXH9MzcyTcMSFp/60yUXH4rdHYZElF7XCdiE
63WxebxI1Qza4xkjTlbp5EWfWBQB1Ms10JO8NjrtkCXrDI57Bij5YanPAVhctcO9
z5/y9i5xEzcer8ZLO8VDiXSdEsuP/fe+UKDyYHUITD8u51p3O2JwCKvdTHduemej
3Kd1RlHrAgMBAAECggEATWdzpASkQpcSdjPSb21JIIAt5VAmJ2YKuYjyPMdVh1qe
Kdn7KJpZlFwRMBFrZjgn35Nmu1A4BFwbK5UdKUcCjvsABL+cTFsu8ORI+Fpi9+Tl
r6gGUfQhkXF85bhBfN6n9P2J2akxrz/njrf6wXrrL+V5C498tQuus1YFls0+zIpD
N+GngNOPHlGeY3gW4K/HjGuHwuJOvWNmE4KNQhBijdd50Am824Y4NV/SmsIo7z+s
8CLjp/qtihwnE4rkUHnR6M4u5lpzXOnodzkDTG8euOJds0T8DwLNTx1b+ETim35i
D/hOCVwl8QFoj2aatjuJ5LXZtZUEpGpBF2TQecB+gQKBgQDvaZ1jG/FNPnKdayYv
z5yTOhKM6JTB+WjB0GSx8rebtbFppiHGgVhOd1bLIzli9uMOPdCNuXh7CKzIgSA6
Q76Wxfuaw8F6CBIdlG9bZNL6x8wp6zF8tGz/BgW7fFKBwFYSWzTcStGr2QGtwr6F
9p1gYPSGfdERGOQc7RmhoNNHcQKBgQDqfkhpPfJlP/SdFnF7DDUvuMnaswzUsM6D
ZPhvfzdMBV8jGc0WjCW2Vd3pvsdPgWXZqAKjN7+A5HiT/8qv5ruoqOJSR9ZFZI/B
8v+8gS9Af7K56mCuCFKZmOXUmaL+3J2FKtzAyOlSLjEYyLuCgmhEA9Zo+duGR5xX
AIjx7N/ZGwKBgCZAYqQeJ8ymqJtcLkq/Sg3/3kzjMDlZxxIIYL5JwGpBemod4BGe
QuSujpCAPUABoD97QuIR+xz1Qt36O5LzlfTzBwMwOa5ssbBGMhCRKGBnIcikylBZ
Z3zLkojlES2n9FiUd/qmfZ+OWYVQsy4mO/jVJNyEJ64qou+4NjsrvfYRAoGAORki
3K1+1nSqRY3vd/zS/pnKXPx4RVoADzKI4+1gM5yjO9LOg40AqdNiw8X2lj9143fr
nH64nNQFIFSKsCZIz5q/8TUY0bDY6GsZJnd2YAg4JtkRTY8tPcVjQU9fxxtFJ+X1
9uN1HNOulNBcCD1k0hr1HH6qm5nYUb8JmY8KOr0CgYB85pvPhBqqfcWi6qaVQtK1
ukIdiJtMNPwePfsT/2KqrbnftQnAKNnhsgcYGo8NAvntX4FokOAEdunyYmm85mLp
BGKYgVXJqnm6+TJyCRac1ro3noG898P/LZ8MOBoaYQtWeWRpDc46jPrA0FqUJy+i
ca/T0LLtgmbMmxSv/MmzIg==
-----END PRIVATE KEY-----`,
requestUri: "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c"
};
function getMockAuthorizationServer({ tokenEndpointResponse, tokenEndpointErrorResponse, tokenEndpointFetchError, discoveryResponse, audience, nonce, keyPair = DEFAULT.keyPair, onParRequest, onBackchannelAuthRequest } = {}) {
// this function acts as a mock authorization server
return vi.fn(async (input, init) => {
let url;
if (input instanceof Request) {
url = new URL(input.url);
}
else {
url = new URL(input);
}
if (url.pathname === "/oauth/token") {
if (tokenEndpointFetchError) {
throw tokenEndpointFetchError;
}
const jwt = await new jose.SignJWT({
sid: DEFAULT.sid,
auth_time: Date.now(),
nonce: nonce ?? "nonce-value",
"https://example.com/custom_claim": "value"
})
.setProtectedHeader({ alg: DEFAULT.alg })
.setSubject(DEFAULT.sub)
.setIssuedAt()
.setIssuer(_authorizationServerMetadata.issuer)
.setAudience(audience ?? DEFAULT.clientId)
.setExpirationTime("2h")
.sign(keyPair.privateKey);
if (tokenEndpointErrorResponse) {
return Response.json(tokenEndpointErrorResponse, {
status: 400
});
}
return Response.json(tokenEndpointResponse ?? {
token_type: "Bearer",
access_token: DEFAULT.accessToken,
refresh_token: DEFAULT.refreshToken,
id_token: jwt,
expires_in: 86400 // expires in 10 days
});
}
// discovery URL
if (url.pathname === "/.well-known/openid-configuration") {
return (discoveryResponse ?? Response.json(_authorizationServerMetadata));
}
// PAR endpoint
if (url.pathname === "/oauth/par") {
if (onParRequest) {
await onParRequest(new Request(input, init));
}
return Response.json({ request_uri: DEFAULT.requestUri, expires_in: 30 }, {
status: 201
});
}
// Backchannel Authorize endpoint
if (url.pathname === "/bc-authorize") {
if (onBackchannelAuthRequest) {
await onBackchannelAuthRequest(new Request(input, init));
}
return Response.json({
auth_req_id: "auth-req-id",
expires_in: 30,
interval: 0.01
}, {
status: 200
});
}
return new Response(null, { status: 404 });
});
}
async function generateLogoutToken({ claims = {}, audience = DEFAULT.clientId, issuer = _authorizationServerMetadata.issuer, alg = DEFAULT.alg, privateKey = DEFAULT.keyPair.privateKey }) {
return await new jose.SignJWT({
events: {
"http://schemas.openid.net/event/backchannel-logout": {}
},
sub: DEFAULT.sub,
sid: DEFAULT.sid,
...claims
})
.setProtectedHeader({ alg, typ: "logout+jwt" })
.setIssuedAt()
.setIssuer(issuer)
.setAudience(audience)
.setExpirationTime("2h")
.setJti("some-jti")
.sign(privateKey);
}
async function getCachedJWKS() {
const publicJwk = await jose.exportJWK(DEFAULT.keyPair.publicKey);
return {
jwks: {
keys: [publicJwk]
},
uat: Date.now() - 1000 * 60
};
}
describe("initialization", async () => {
it("should throw an error if the openid scope is not included", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
expect(() => new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
authorizationParameters: {
scope: "profile email"
},
fetch: getMockAuthorizationServer()
})).toThrowError();
});
});
describe("handler", async () => {
it("should call the login handler if the path is /auth/login", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const request = new NextRequest("https://example.com/auth/login", {
method: "GET"
});
authClient.handleLogin = vi.fn();
await authClient.handler(request);
expect(authClient.handleLogin).toHaveBeenCalled();
});
it("should call the callback handler if the path is /auth/callback", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const request = new NextRequest("https://example.com/auth/callback", {
method: "GET"
});
authClient.handleCallback = vi.fn();
await authClient.handler(request);
expect(authClient.handleCallback).toHaveBeenCalled();
});
it("should call the logout handler if the path is /auth/logout", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const request = new NextRequest("https://example.com/auth/logout", {
method: "GET"
});
authClient.handleLogout = vi.fn();
await authClient.handler(request);
expect(authClient.handleLogout).toHaveBeenCalled();
});
it("should call the profile handler if the path is /auth/profile", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const request = new NextRequest("https://example.com/auth/profile", {
method: "GET"
});
authClient.handleProfile = vi.fn();
await authClient.handler(request);
expect(authClient.handleProfile).toHaveBeenCalled();
});
it("should call the handleAccessToken method if the path is /auth/access-token and enableAccessTokenEndpoint is true", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
enableAccessTokenEndpoint: true,
fetch: getMockAuthorizationServer()
});
const request = new NextRequest("https://example.com/auth/access-token", {
method: "GET"
});
authClient.handleAccessToken = vi.fn();
await authClient.handler(request);
expect(authClient.handleAccessToken).toHaveBeenCalled();
});
it("should not call the handleAccessToken method if the path is /auth/access-token but enableAccessTokenEndpoint is false", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
enableAccessTokenEndpoint: false,
fetch: getMockAuthorizationServer()
});
const request = new NextRequest("https://example.com/auth/access-token", {
method: "GET"
});
authClient.handleAccessToken = vi.fn();
const response = await authClient.handler(request);
expect(authClient.handleAccessToken).not.toHaveBeenCalled();
// When a route doesn't match, the handler returns a NextResponse.next() with status 200
expect(response.status).toBe(200);
});
it("should use the default value (true) for enableAccessTokenEndpoint when not explicitly provided", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
// enableAccessTokenEndpoint not specified, should default to true
fetch: getMockAuthorizationServer()
});
const request = new NextRequest("https://example.com/auth/access-token", {
method: "GET"
});
authClient.handleAccessToken = vi.fn();
await authClient.handler(request);
expect(authClient.handleAccessToken).toHaveBeenCalled();
});
it("should call the back-channel logout handler if the path is /auth/backchannel-logout", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const request = new NextRequest("https://example.com/auth/backchannel-logout", {
method: "POST"
});
authClient.handleBackChannelLogout = vi.fn();
await authClient.handler(request);
expect(authClient.handleBackChannelLogout).toHaveBeenCalled();
});
describe("rolling sessions - no matching auth route", async () => {
it("should update the session expiry if a session exists", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret,
rolling: true,
absoluteDuration: 3600,
inactivityDuration: 1800
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const session = {
user: { sub: DEFAULT.sub },
tokenSet: {
accessToken: DEFAULT.accessToken,
refreshToken: DEFAULT.refreshToken,
expiresAt: 123456
},
internal: {
sid: DEFAULT.sid,
createdAt: Math.floor(Date.now() / 1000)
}
};
const maxAge = 60 * 60; // 1 hour
const expiration = Math.floor(Date.now() / 1000 + maxAge);
const sessionCookie = await encrypt(session, secret, expiration);
const headers = new Headers();
headers.append("cookie", `__session=${sessionCookie}`);
const request = new NextRequest("https://example.com/dashboard/projects", {
method: "GET",
headers
});
const response = await authClient.handler(request);
// assert session has been updated
const updatedSessionCookie = response.cookies.get("__session");
expect(updatedSessionCookie).toBeDefined();
const { payload: updatedSessionCookieValue } = (await decrypt(updatedSessionCookie.value, secret));
expect(updatedSessionCookieValue).toEqual(expect.objectContaining({
user: {
sub: DEFAULT.sub
},
tokenSet: {
accessToken: "at_123",
refreshToken: "rt_123",
expiresAt: expect.any(Number)
},
internal: {
sid: DEFAULT.sid,
createdAt: expect.any(Number)
}
}));
// assert that the session expiry has been extended by the inactivity duration
expect(updatedSessionCookie?.maxAge).toEqual(1800);
});
it("should pass the request through if there is no session", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret,
rolling: true,
absoluteDuration: 3600,
inactivityDuration: 1800
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const request = new NextRequest("https://example.com/dashboard/projects", {
method: "GET"
});
authClient.getTokenSet = vi.fn();
const response = await authClient.handler(request);
expect(authClient.getTokenSet).not.toHaveBeenCalled();
// assert session has not been updated
const updatedSessionCookie = response.cookies.get("__session");
expect(updatedSessionCookie).toBeUndefined();
});
});
describe("with custom routes", async () => {
it("should call the login handler when the configured route is called", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
fetch: getMockAuthorizationServer(),
routes: {
...getDefaultRoutes(),
login: "/custom-login"
}
});
const request = new NextRequest(new URL("/custom-login", DEFAULT.appBaseUrl), {
method: "GET"
});
authClient.handleLogin = vi.fn();
await authClient.handler(request);
expect(authClient.handleLogin).toHaveBeenCalled();
});
it("should call the logout handler when the configured route is called", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
fetch: getMockAuthorizationServer(),
routes: {
...getDefaultRoutes(),
logout: "/custom-logout"
}
});
const request = new NextRequest(new URL("/custom-logout", DEFAULT.appBaseUrl), {
method: "GET"
});
authClient.handleLogout = vi.fn();
await authClient.handler(request);
expect(authClient.handleLogout).toHaveBeenCalled();
});
it("should call the callback handler when the configured route is called", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
fetch: getMockAuthorizationServer(),
routes: {
...getDefaultRoutes(),
callback: "/custom-callback"
}
});
const request = new NextRequest(new URL("/custom-callback", DEFAULT.appBaseUrl), {
method: "GET"
});
authClient.handleCallback = vi.fn();
await authClient.handler(request);
expect(authClient.handleCallback).toHaveBeenCalled();
});
it("should call the backChannelLogout handler when the configured route is called", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
fetch: getMockAuthorizationServer(),
routes: {
...getDefaultRoutes(),
backChannelLogout: "/custom-backchannel-logout"
}
});
const request = new NextRequest(new URL("/custom-backchannel-logout", DEFAULT.appBaseUrl), {
method: "POST"
});
authClient.handleBackChannelLogout = vi.fn();
await authClient.handler(request);
expect(authClient.handleBackChannelLogout).toHaveBeenCalled();
});
it("should call the profile handler when the configured route is called", async () => {
process.env.NEXT_PUBLIC_PROFILE_ROUTE = "/custom-profile";
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const request = new NextRequest(new URL("/custom-profile", DEFAULT.appBaseUrl), {
method: "GET"
});
authClient.handleProfile = vi.fn();
await authClient.handler(request);
expect(authClient.handleProfile).toHaveBeenCalled();
delete process.env.NEXT_PUBLIC_PROFILE_ROUTE;
});
it("should call the access-token handler when the configured route is called", async () => {
process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE = "/custom-access-token";
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const request = new NextRequest(new URL("/custom-access-token", DEFAULT.appBaseUrl), {
method: "GET"
});
authClient.handleAccessToken = vi.fn();
await authClient.handler(request);
expect(authClient.handleAccessToken).toHaveBeenCalled();
delete process.env.NEXT_PUBLIC_ACCESS_TOKEN_ROUTE;
});
});
describe("with a base path", async () => {
beforeAll(() => {
process.env.NEXT_PUBLIC_BASE_PATH = "/base-path";
});
afterAll(() => {
delete process.env.NEXT_PUBLIC_BASE_PATH;
});
it("should call the appropriate handlers when routes are called with base path", async () => {
const testCases = [
{
path: "/auth/login",
method: "GET",
handler: "handleLogin"
},
{
path: "/auth/logout",
method: "GET",
handler: "handleLogout"
},
{
path: "/auth/callback",
method: "GET",
handler: "handleCallback"
},
{
path: "/auth/backchannel-logout",
method: "POST",
handler: "handleBackChannelLogout"
},
{
path: "/auth/profile",
method: "GET",
handler: "handleProfile"
},
{
path: "/auth/access-token",
method: "GET",
handler: "handleAccessToken"
}
];
for (const testCase of testCases) {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const request = new NextRequest(
// Next.js will strip the base path from the URL
new URL(testCase.path, `${DEFAULT.appBaseUrl}/${process.env.NEXT_PUBLIC_BASE_PATH}`), {
method: testCase.method
});
authClient[testCase.handler] = vi.fn();
await authClient.handler(request);
expect(authClient[testCase.handler]).toHaveBeenCalled();
}
});
});
});
describe("handleLogin", async () => {
it("should redirect to the authorization server and store the transaction state", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const request = new NextRequest(new URL("/auth/login", DEFAULT.appBaseUrl), {
method: "GET"
});
const response = await authClient.handleLogin(request);
expect(response.status).toEqual(307);
expect(response.headers.get("Location")).not.toBeNull();
const authorizationUrl = new URL(response.headers.get("Location"));
expect(authorizationUrl.origin).toEqual(`https://${DEFAULT.domain}`);
// query parameters
expect(authorizationUrl.searchParams.get("client_id")).toEqual(DEFAULT.clientId);
expect(authorizationUrl.searchParams.get("redirect_uri")).toEqual(`${DEFAULT.appBaseUrl}/auth/callback`);
expect(authorizationUrl.searchParams.get("response_type")).toEqual("code");
expect(authorizationUrl.searchParams.get("code_challenge")).not.toBeNull();
expect(authorizationUrl.searchParams.get("code_challenge_method")).toEqual("S256");
expect(authorizationUrl.searchParams.get("state")).not.toBeNull();
expect(authorizationUrl.searchParams.get("nonce")).not.toBeNull();
expect(authorizationUrl.searchParams.get("scope")).toEqual("openid profile email offline_access");
// transaction state
const transactionCookie = response.cookies.get(`__txn_${authorizationUrl.searchParams.get("state")}`);
expect(transactionCookie).toBeDefined();
expect((await decrypt(transactionCookie.value, secret)).payload).toEqual(expect.objectContaining({
nonce: authorizationUrl.searchParams.get("nonce"),
codeVerifier: expect.any(String),
responseType: "code",
state: authorizationUrl.searchParams.get("state"),
returnTo: "/"
}));
});
it("should configure redirect_uri when appBaseUrl isnt the root", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: `${DEFAULT.appBaseUrl}/sub-path`,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const request = new NextRequest(new URL("/auth/login", DEFAULT.appBaseUrl), {
method: "GET"
});
const response = await authClient.handleLogin(request);
const authorizationUrl = new URL(response.headers.get("Location"));
expect(authorizationUrl.searchParams.get("redirect_uri")).toEqual(`${DEFAULT.appBaseUrl}/sub-path/auth/callback`);
});
describe("with a base path", async () => {
beforeAll(() => {
process.env.NEXT_PUBLIC_BASE_PATH = "/base-path";
});
afterAll(() => {
delete process.env.NEXT_PUBLIC_BASE_PATH;
});
it("should prepend the base path to the redirect_uri", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: `${DEFAULT.appBaseUrl}`,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const request = new NextRequest(new URL(process.env.NEXT_PUBLIC_BASE_PATH + "/auth/login", DEFAULT.appBaseUrl), {
method: "GET"
});
const response = await authClient.handleLogin(request);
const authorizationUrl = new URL(response.headers.get("Location"));
expect(authorizationUrl.searchParams.get("redirect_uri")).toEqual(`${DEFAULT.appBaseUrl}/base-path/auth/callback`);
});
});
it("should return an error if the discovery endpoint could not be fetched", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer({
discoveryResponse: new Response(null, { status: 500 })
})
});
const request = new NextRequest(new URL("/auth/login", DEFAULT.appBaseUrl), {
method: "GET"
});
const response = await authClient.handleLogin(request);
expect(response.status).toEqual(500);
expect(await response.text()).toContain("An error occured while trying to initiate the login request.");
});
describe("authorization parameters", async () => {
it("should forward the query parameters to the authorization server", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const loginUrl = new URL("/auth/login", DEFAULT.appBaseUrl);
loginUrl.searchParams.set("custom_param", "custom_value");
loginUrl.searchParams.set("audience", "urn:mystore:api");
const request = new NextRequest(loginUrl, {
method: "GET"
});
const response = await authClient.handleLogin(request);
expect(response.status).toEqual(307);
expect(response.headers.get("Location")).not.toBeNull();
const authorizationUrl = new URL(response.headers.get("Location"));
expect(authorizationUrl.origin).toEqual(`https://${DEFAULT.domain}`);
// query parameters
expect(authorizationUrl.searchParams.get("client_id")).toEqual(DEFAULT.clientId);
expect(authorizationUrl.searchParams.get("redirect_uri")).toEqual(`${DEFAULT.appBaseUrl}/auth/callback`);
expect(authorizationUrl.searchParams.get("response_type")).toEqual("code");
expect(authorizationUrl.searchParams.get("code_challenge")).not.toBeNull();
expect(authorizationUrl.searchParams.get("code_challenge_method")).toEqual("S256");
expect(authorizationUrl.searchParams.get("state")).not.toBeNull();
expect(authorizationUrl.searchParams.get("nonce")).not.toBeNull();
expect(authorizationUrl.searchParams.get("scope")).toEqual("openid profile email offline_access");
expect(authorizationUrl.searchParams.get("custom_param")).toEqual("custom_value");
expect(authorizationUrl.searchParams.get("audience")).toEqual("urn:mystore:api");
// transaction state
const transactionCookie = response.cookies.get(`__txn_${authorizationUrl.searchParams.get("state")}`);
expect(transactionCookie).toBeDefined();
expect((await decrypt(transactionCookie.value, secret)).payload).toEqual(expect.objectContaining({
nonce: authorizationUrl.searchParams.get("nonce"),
codeVerifier: expect.any(String),
responseType: "code",
state: authorizationUrl.searchParams.get("state"),
returnTo: "/"
}));
});
it("should forward the configured authorization parameters to the authorization server", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
authorizationParameters: {
scope: "openid profile email offline_access custom_scope",
audience: "urn:mystore:api",
custom_param: "custom_value"
},
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const loginUrl = new URL("/auth/login", DEFAULT.appBaseUrl);
const request = new NextRequest(loginUrl, {
method: "GET"
});
const response = await authClient.handleLogin(request);
expect(response.status).toEqual(307);
expect(response.headers.get("Location")).not.toBeNull();
const authorizationUrl = new URL(response.headers.get("Location"));
expect(authorizationUrl.origin).toEqual(`https://${DEFAULT.domain}`);
// query parameters
expect(authorizationUrl.searchParams.get("client_id")).toEqual(DEFAULT.clientId);
expect(authorizationUrl.searchParams.get("redirect_uri")).toEqual(`${DEFAULT.appBaseUrl}/auth/callback`);
expect(authorizationUrl.searchParams.get("response_type")).toEqual("code");
expect(authorizationUrl.searchParams.get("code_challenge")).not.toBeNull();
expect(authorizationUrl.searchParams.get("code_challenge_method")).toEqual("S256");
expect(authorizationUrl.searchParams.get("state")).not.toBeNull();
expect(authorizationUrl.searchParams.get("nonce")).not.toBeNull();
expect(authorizationUrl.searchParams.get("scope")).toEqual("openid profile email offline_access custom_scope");
expect(authorizationUrl.searchParams.get("custom_param")).toEqual("custom_value");
expect(authorizationUrl.searchParams.get("audience")).toEqual("urn:mystore:api");
});
it("should override the configured authorization parameters with the query parameters", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
authorizationParameters: {
audience: "from-config",
custom_param: "from-config"
},
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const loginUrl = new URL("/auth/login", DEFAULT.appBaseUrl);
loginUrl.searchParams.set("custom_param", "from-query");
loginUrl.searchParams.set("audience", "from-query");
const request = new NextRequest(loginUrl, {
method: "GET"
});
const response = await authClient.handleLogin(request);
expect(response.status).toEqual(307);
expect(response.headers.get("Location")).not.toBeNull();
const authorizationUrl = new URL(response.headers.get("Location"));
expect(authorizationUrl.origin).toEqual(`https://${DEFAULT.domain}`);
// query parameters
expect(authorizationUrl.searchParams.get("client_id")).toEqual(DEFAULT.clientId);
expect(authorizationUrl.searchParams.get("redirect_uri")).toEqual(`${DEFAULT.appBaseUrl}/auth/callback`);
expect(authorizationUrl.searchParams.get("response_type")).toEqual("code");
expect(authorizationUrl.searchParams.get("code_challenge")).not.toBeNull();
expect(authorizationUrl.searchParams.get("code_challenge_method")).toEqual("S256");
expect(authorizationUrl.searchParams.get("state")).not.toBeNull();
expect(authorizationUrl.searchParams.get("nonce")).not.toBeNull();
expect(authorizationUrl.searchParams.get("scope")).toEqual("openid profile email offline_access");
expect(authorizationUrl.searchParams.get("custom_param")).toEqual("from-query");
expect(authorizationUrl.searchParams.get("audience")).toEqual("from-query");
});
it("should not override internal authorization parameter values", async () => {
const secret = await generateSecret(32);
const transactionStore = new TransactionStore({
secret
});
const sessionStore = new StatelessSessionStore({
secret
});
const authClient = new AuthClient({
transactionStore,
sessionStore,
domain: DEFAULT.domain,
clientId: DEFAULT.clientId,
clientSecret: DEFAULT.clientSecret,
authorizationParameters: {
client_id: "from-config",
redirect_uri: "from-config",
response_type: "from-config",
code_challenge: "from-config",
code_challenge_method: "from-config",
state: "from-config",
nonce: "from-config",
// allowed to be overridden
custom_param: "from-config",
scope: "openid profile email offline_access custom_scope",
audience: "from-config"
},
secret,
appBaseUrl: DEFAULT.appBaseUrl,
routes: getDefaultRoutes(),
fetch: getMockAuthorizationServer()
});
const loginUrl = new URL("/auth/login", DEFAULT.appBaseUrl);
loginUrl.searchParams.set("client_id", "from-query");
loginUrl.searchParams.set("redirect_uri", "from-query");
loginUrl.searchParams.set("response_type", "from-query");