Skip to content

Commit 2ac2d31

Browse files
Programmatic PAR (#1946)
2 parents 3b74cef + cd2a7db commit 2ac2d31

File tree

5 files changed

+360
-42
lines changed

5 files changed

+360
-42
lines changed

EXAMPLES.md

+30
Original file line numberDiff line numberDiff line change
@@ -752,3 +752,33 @@ const sessionCookieValue = await generateSessionCookie(
752752
}
753753
)
754754
```
755+
756+
757+
## Programmatic Pushed Authentication Requests (PAR)
758+
759+
The method `startInteractiveLogin` can be called with authorizationParams to initiate an interactive login flow.
760+
The code collects authorization parameters on the server side rather than constructing them directly in the browser.
761+
762+
```typescript
763+
// app/api/auth/login/route.ts
764+
import { auth0 } from "./lib/auth0";
765+
import { NextRequest } from "next/server";
766+
767+
export const GET = async (req: NextRequest) => {
768+
// Extract custom parameters from request URL if needed
769+
const searchParams = Object.fromEntries(req.nextUrl.searchParams.entries());
770+
771+
// Call startInteractiveLogin with optional parameters
772+
return auth0.startInteractiveLogin({
773+
// a custom returnTo URL can be specified
774+
returnTo: "/dashboard",
775+
authorizationParameters: {
776+
prompt: searchParams.prompt,
777+
login_hint: searchParams.login_hint,
778+
// Add any custom auth parameters if required
779+
audience: "custom-audience"
780+
}
781+
});
782+
};
783+
784+
```

src/server/auth-client.test.ts

+244
Original file line numberDiff line numberDiff line change
@@ -4214,6 +4214,250 @@ ca/T0LLtgmbMmxSv/MmzIg==
42144214
});
42154215
});
42164216
});
4217+
4218+
describe("startInteractiveLogin", async () => {
4219+
const createAuthClient = async ({
4220+
pushedAuthorizationRequests = false,
4221+
signInReturnToPath = "/",
4222+
authorizationParameters = {}
4223+
} = {}) => {
4224+
const secret = await generateSecret(32);
4225+
const transactionStore = new TransactionStore({
4226+
secret
4227+
});
4228+
const sessionStore = new StatelessSessionStore({
4229+
secret
4230+
});
4231+
4232+
return new AuthClient({
4233+
transactionStore,
4234+
sessionStore,
4235+
4236+
domain: DEFAULT.domain,
4237+
clientId: DEFAULT.clientId,
4238+
clientSecret: DEFAULT.clientSecret,
4239+
4240+
secret,
4241+
appBaseUrl: DEFAULT.appBaseUrl,
4242+
signInReturnToPath,
4243+
pushedAuthorizationRequests,
4244+
authorizationParameters: {
4245+
scope: "openid profile email",
4246+
...authorizationParameters
4247+
},
4248+
4249+
fetch: getMockAuthorizationServer()
4250+
});
4251+
};
4252+
4253+
it("should use the default returnTo path when no returnTo is provided", async () => {
4254+
const defaultReturnTo = "/default-path";
4255+
const authClient = await createAuthClient({
4256+
signInReturnToPath: defaultReturnTo
4257+
});
4258+
4259+
// Mock the transactionStore.save method to verify the saved state
4260+
const originalSave = authClient['transactionStore'].save;
4261+
authClient['transactionStore'].save = vi.fn(async (cookies, state) => {
4262+
expect(state.returnTo).toBe(defaultReturnTo);
4263+
return originalSave.call(authClient['transactionStore'], cookies, state);
4264+
});
4265+
4266+
await authClient.startInteractiveLogin();
4267+
4268+
expect(authClient['transactionStore'].save).toHaveBeenCalled();
4269+
});
4270+
4271+
it("should sanitize and use the provided returnTo parameter", async () => {
4272+
const authClient = await createAuthClient();
4273+
const returnTo = "/custom-return-path";
4274+
4275+
// Mock the transactionStore.save method to verify the saved state
4276+
const originalSave = authClient['transactionStore'].save;
4277+
authClient['transactionStore'].save = vi.fn(async (cookies, state) => {
4278+
// The full URL is saved, not just the path
4279+
expect(state.returnTo).toBe("https://example.com/custom-return-path");
4280+
return originalSave.call(authClient['transactionStore'], cookies, state);
4281+
});
4282+
4283+
await authClient.startInteractiveLogin({ returnTo });
4284+
4285+
expect(authClient['transactionStore'].save).toHaveBeenCalled();
4286+
});
4287+
4288+
it("should reject unsafe returnTo URLs", async () => {
4289+
const authClient = await createAuthClient({
4290+
signInReturnToPath: "/safe-path"
4291+
});
4292+
const unsafeReturnTo = "https://malicious-site.com";
4293+
4294+
// Mock the transactionStore.save method to verify the saved state
4295+
const originalSave = authClient['transactionStore'].save;
4296+
authClient['transactionStore'].save = vi.fn(async (cookies, state) => {
4297+
// Should use the default safe path instead of the malicious one
4298+
expect(state.returnTo).toBe("/safe-path");
4299+
return originalSave.call(authClient['transactionStore'], cookies, state);
4300+
});
4301+
4302+
await authClient.startInteractiveLogin({ returnTo: unsafeReturnTo });
4303+
4304+
expect(authClient['transactionStore'].save).toHaveBeenCalled();
4305+
});
4306+
4307+
it("should pass authorization parameters to the authorization URL", async () => {
4308+
const authClient = await createAuthClient();
4309+
const authorizationParameters = {
4310+
audience: "https://api.example.com",
4311+
scope: "openid profile email custom_scope"
4312+
};
4313+
4314+
// Spy on the authorizationUrl method to verify the passed params
4315+
const originalAuthorizationUrl = authClient['authorizationUrl'];
4316+
authClient['authorizationUrl'] = vi.fn(async (params) => {
4317+
// Verify the audience is set correctly
4318+
expect(params.get("audience")).toBe(authorizationParameters.audience);
4319+
// Verify the scope is set correctly
4320+
expect(params.get("scope")).toBe(authorizationParameters.scope);
4321+
return originalAuthorizationUrl.call(authClient, params);
4322+
});
4323+
4324+
await authClient.startInteractiveLogin({ authorizationParameters });
4325+
4326+
expect(authClient['authorizationUrl']).toHaveBeenCalled();
4327+
});
4328+
4329+
it("should handle pushed authorization requests (PAR) correctly", async () => {
4330+
let parRequestCalled = false;
4331+
const mockFetch = getMockAuthorizationServer({
4332+
onParRequest: async () => {
4333+
parRequestCalled = true;
4334+
}
4335+
});
4336+
4337+
const secret = await generateSecret(32);
4338+
const transactionStore = new TransactionStore({ secret });
4339+
const sessionStore = new StatelessSessionStore({ secret });
4340+
4341+
const authClient = new AuthClient({
4342+
transactionStore,
4343+
sessionStore,
4344+
domain: DEFAULT.domain,
4345+
clientId: DEFAULT.clientId,
4346+
clientSecret: DEFAULT.clientSecret,
4347+
secret,
4348+
appBaseUrl: DEFAULT.appBaseUrl,
4349+
pushedAuthorizationRequests: true,
4350+
authorizationParameters: {
4351+
scope: "openid profile email"
4352+
},
4353+
fetch: mockFetch
4354+
});
4355+
4356+
await authClient.startInteractiveLogin();
4357+
4358+
// Verify that PAR was used
4359+
expect(parRequestCalled).toBe(true);
4360+
});
4361+
4362+
it("should save the transaction state with correct values", async () => {
4363+
const authClient = await createAuthClient();
4364+
const returnTo = "/custom-path";
4365+
4366+
// Instead of mocking the oauth functions, we'll just check the structure of the transaction state
4367+
const originalSave = authClient['transactionStore'].save;
4368+
authClient['transactionStore'].save = vi.fn(async (cookies, transactionState) => {
4369+
expect(transactionState).toEqual(expect.objectContaining({
4370+
nonce: expect.any(String),
4371+
codeVerifier: expect.any(String),
4372+
responseType: "code",
4373+
state: expect.any(String),
4374+
returnTo: "https://example.com/custom-path"
4375+
}));
4376+
return originalSave.call(authClient['transactionStore'], cookies, transactionState);
4377+
});
4378+
4379+
await authClient.startInteractiveLogin({ returnTo });
4380+
4381+
expect(authClient['transactionStore'].save).toHaveBeenCalled();
4382+
});
4383+
4384+
it("should merge configuration authorizationParameters with method arguments", async () => {
4385+
const configScope = "openid profile email";
4386+
const configAudience = "https://default-api.example.com";
4387+
const authClient = await createAuthClient({
4388+
authorizationParameters: {
4389+
scope: configScope,
4390+
audience: configAudience
4391+
}
4392+
});
4393+
4394+
const methodScope = "openid profile email custom_scope";
4395+
const methodAudience = "https://custom-api.example.com";
4396+
4397+
// Spy on the authorizationUrl method to verify the passed params
4398+
const originalAuthorizationUrl = authClient['authorizationUrl'];
4399+
authClient['authorizationUrl'] = vi.fn(async (params) => {
4400+
// Method's authorization parameters should override config
4401+
expect(params.get("audience")).toBe(methodAudience);
4402+
expect(params.get("scope")).toBe(methodScope);
4403+
return originalAuthorizationUrl.call(authClient, params);
4404+
});
4405+
4406+
await authClient.startInteractiveLogin({
4407+
authorizationParameters: {
4408+
scope: methodScope,
4409+
audience: methodAudience
4410+
}
4411+
});
4412+
4413+
expect(authClient['authorizationUrl']).toHaveBeenCalled();
4414+
});
4415+
4416+
// Add tests for handleLogin method
4417+
it("should create correct options in handleLogin with returnTo parameter", async () => {
4418+
const authClient = await createAuthClient();
4419+
4420+
// Mock startInteractiveLogin to check what options are passed to it
4421+
const originalStartInteractiveLogin = authClient.startInteractiveLogin;
4422+
authClient.startInteractiveLogin = vi.fn(async (options) => {
4423+
expect(options).toEqual({
4424+
authorizationParameters: { foo: "bar", returnTo: "custom-return" },
4425+
returnTo: "custom-return"
4426+
});
4427+
return originalStartInteractiveLogin.call(authClient, options);
4428+
});
4429+
4430+
const reqUrl = new URL("https://example.com/auth/login?foo=bar&returnTo=custom-return");
4431+
const req = new NextRequest(reqUrl, { method: "GET" });
4432+
4433+
await authClient.handleLogin(req);
4434+
4435+
expect(authClient.startInteractiveLogin).toHaveBeenCalled();
4436+
});
4437+
4438+
it("should handle PAR correctly in handleLogin by not forwarding params", async () => {
4439+
const authClient = await createAuthClient({
4440+
pushedAuthorizationRequests: true
4441+
});
4442+
4443+
// Mock startInteractiveLogin to check what options are passed to it
4444+
const originalStartInteractiveLogin = authClient.startInteractiveLogin;
4445+
authClient.startInteractiveLogin = vi.fn(async (options) => {
4446+
expect(options).toEqual({
4447+
authorizationParameters: {},
4448+
returnTo: "custom-return"
4449+
});
4450+
return originalStartInteractiveLogin.call(authClient, options);
4451+
});
4452+
4453+
const reqUrl = new URL("https://example.com/auth/login?foo=bar&returnTo=custom-return");
4454+
const req = new NextRequest(reqUrl, { method: "GET" });
4455+
4456+
await authClient.handleLogin(req);
4457+
4458+
expect(authClient.startInteractiveLogin).toHaveBeenCalled();
4459+
});
4460+
});
42174461
});
42184462

42194463
const _authorizationServerMetadata = {

0 commit comments

Comments
 (0)