Skip to content

Extensive Cookie Configuration #2059

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
35 changes: 35 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
- [`beforeSessionSaved`](#beforesessionsaved)
- [`onCallback`](#oncallback)
- [Session configuration](#session-configuration)
- [Cookie Configuration](#cookie-configuration)
- [Database sessions](#database-sessions)
- [Back-Channel Logout](#back-channel-logout)
- [Combining middleware](#combining-middleware)
Expand Down Expand Up @@ -626,6 +627,40 @@ export const auth0 = new Auth0Client({
| absoluteDuration | `number` | The absolute duration after which the session will expire. The value must be specified in seconds. Default: `3 days`. |
| inactivityDuration | `number` | The duration of inactivity after which the session will expire. The value must be specified in seconds. Default: `1 day`. |

## Cookie Configuration

You can configure session cookie attributes directly in the client options. These options take precedence over environment variables (`AUTH0_COOKIE_*`).

```ts
import { Auth0Client } from "@auth0/nextjs-auth0/server"

export const auth0 = new Auth0Client({
session: {
cookie: {
domain: ".example.com", // Set cookie for subdomains
path: "/app", // Limit cookie to /app path
transient: true, // Make cookie transient (session-only, ignores maxAge)
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "Lax",
// name: 'appSession', // Optional: custom cookie name, defaults to '__session'
},
// ... other session options like absoluteDuration ...
},
// ... other client options ...
})
```

**Cookie Options:**

* `domain` (String): Specifies the `Domain` attribute.
* `path` (String): Specifies the `Path` attribute. Defaults to `/`.
* `transient` (Boolean): If `true`, the `maxAge` attribute is omitted, making it a session cookie. Defaults to `false`.
* `httpOnly` (Boolean): Specifies the `HttpOnly` attribute. Defaults to `true`.
* `secure` (Boolean): Specifies the `Secure` attribute. Defaults to `false` (or `true` if `AUTH0_COOKIE_SECURE=true` is set).
* `sameSite` ('Lax' | 'Strict' | 'None'): Specifies the `SameSite` attribute. Defaults to `Lax` (or the value of `AUTH0_COOKIE_SAME_SITE`).
* `name` (String): The name of the session cookie. Defaults to `__session`.

## Database sessions

By default, the user's sessions are stored in encrypted cookies. You may choose to persist the sessions in your data store of choice.
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ You can customize the client by using the options below:
| appBaseUrl | `string` | The URL of your application (e.g.: `http://localhost:3000`). If it's not specified, it will be loaded from the `APP_BASE_URL` environment variable. |
| secret | `string` | A 32-byte, hex-encoded secret used for encrypting cookies. If it's not specified, it will be loaded from the `AUTH0_SECRET` environment variable. |
| signInReturnToPath | `string` | The path to redirect the user to after successfully authenticating. Defaults to `/`. |
| session | `SessionConfiguration` | Configure the session timeouts and whether to use rolling sessions or not. See [Session configuration](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#session-configuration) for additional details. |
| session | `SessionConfiguration` | Configure the session timeouts and whether to use rolling sessions or not. See [Session configuration](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#session-configuration) for additional details. Also allows configuration of cookie attributes like `domain`, `path`, `secure`, `sameSite`, `httpOnly`, and `transient`. If not specified, these can be configured using `AUTH0_COOKIE_*` environment variables. See [Cookie Configuration](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#cookie-configuration) for details. |
| beforeSessionSaved | `BeforeSessionSavedHook` | A method to manipulate the session before persisting it. See [beforeSessionSaved](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#beforesessionsaved) for additional details. |
| onCallback | `OnCallbackHook` | A method to handle errors or manage redirects after attempting to authenticate. See [onCallback](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#oncallback) for additional details. |
| sessionStore | `SessionStore` | A custom session store implementation used to persist sessions to a data store. See [Database sessions](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#database-sessions) for additional details. |
Expand All @@ -147,6 +147,18 @@ You can customize the client by using the options below:
| httpTimeout | `number` | Integer value for the HTTP timeout in milliseconds for authentication requests. Defaults to `5000` milliseconds |
| enableTelemetry | `boolean` | Boolean value to opt-out of sending the library name and version to your authorization server via the `Auth0-Client` header. Defaults to `true`. |

## Session Cookie Configuration
You can specify the following environment variables to configure the session cookie:
```env
AUTH0_COOKIE_DOMAIN=
AUTH0_COOKIE_PATH=
AUTH0_COOKIE_TRANSIENT=
AUTH0_COOKIE_HTTP_ONLY=
AUTH0_COOKIE_SECURE=
AUTH0_COOKIE_SAME_SITE=
```
Respsective counterparts are also available in the client configuration. See [Cookie Configuration](https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#cookie-configuration) for more details.

## Configuration Validation

The SDK performs validation of required configuration options when initializing the `Auth0Client`. The following options are mandatory and must be provided either through constructor options or environment variables:
Expand Down
138 changes: 81 additions & 57 deletions src/server/auth-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ ca/T0LLtgmbMmxSv/MmzIg==
// 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({
Expand Down Expand Up @@ -4374,53 +4374,65 @@ ca/T0LLtgmbMmxSv/MmzIg==
const authClient = await createAuthClient({
signInReturnToPath: defaultReturnTo
});

// Mock the transactionStore.save method to verify the saved state
const originalSave = authClient['transactionStore'].save;
authClient['transactionStore'].save = vi.fn(async (cookies, state) => {
const originalSave = authClient["transactionStore"].save;
authClient["transactionStore"].save = vi.fn(async (cookies, state) => {
expect(state.returnTo).toBe(defaultReturnTo);
return originalSave.call(authClient['transactionStore'], cookies, state);
return originalSave.call(
authClient["transactionStore"],
cookies,
state
);
});

await authClient.startInteractiveLogin();
expect(authClient['transactionStore'].save).toHaveBeenCalled();

expect(authClient["transactionStore"].save).toHaveBeenCalled();
});

it("should sanitize and use the provided returnTo parameter", async () => {
const authClient = await createAuthClient();
const returnTo = "/custom-return-path";

// Mock the transactionStore.save method to verify the saved state
const originalSave = authClient['transactionStore'].save;
authClient['transactionStore'].save = vi.fn(async (cookies, state) => {
const originalSave = authClient["transactionStore"].save;
authClient["transactionStore"].save = vi.fn(async (cookies, state) => {
// The full URL is saved, not just the path
expect(state.returnTo).toBe("https://example.com/custom-return-path");
return originalSave.call(authClient['transactionStore'], cookies, state);
return originalSave.call(
authClient["transactionStore"],
cookies,
state
);
});

await authClient.startInteractiveLogin({ returnTo });
expect(authClient['transactionStore'].save).toHaveBeenCalled();

expect(authClient["transactionStore"].save).toHaveBeenCalled();
});

it("should reject unsafe returnTo URLs", async () => {
const authClient = await createAuthClient({
signInReturnToPath: "/safe-path"
});
const unsafeReturnTo = "https://malicious-site.com";

// Mock the transactionStore.save method to verify the saved state
const originalSave = authClient['transactionStore'].save;
authClient['transactionStore'].save = vi.fn(async (cookies, state) => {
const originalSave = authClient["transactionStore"].save;
authClient["transactionStore"].save = vi.fn(async (cookies, state) => {
// Should use the default safe path instead of the malicious one
expect(state.returnTo).toBe("/safe-path");
return originalSave.call(authClient['transactionStore'], cookies, state);
return originalSave.call(
authClient["transactionStore"],
cookies,
state
);
});

await authClient.startInteractiveLogin({ returnTo: unsafeReturnTo });
expect(authClient['transactionStore'].save).toHaveBeenCalled();

expect(authClient["transactionStore"].save).toHaveBeenCalled();
});

it("should pass authorization parameters to the authorization URL", async () => {
Expand All @@ -4429,10 +4441,10 @@ ca/T0LLtgmbMmxSv/MmzIg==
audience: "https://api.example.com",
scope: "openid profile email custom_scope"
};

// Spy on the authorizationUrl method to verify the passed params
const originalAuthorizationUrl = authClient['authorizationUrl'];
authClient['authorizationUrl'] = vi.fn(async (params) => {
const originalAuthorizationUrl = authClient["authorizationUrl"];
authClient["authorizationUrl"] = vi.fn(async (params) => {
// Verify the audience is set correctly
expect(params.get("audience")).toBe(authorizationParameters.audience);
// Verify the scope is set correctly
Expand All @@ -4441,8 +4453,8 @@ ca/T0LLtgmbMmxSv/MmzIg==
});

await authClient.startInteractiveLogin({ authorizationParameters });
expect(authClient['authorizationUrl']).toHaveBeenCalled();

expect(authClient["authorizationUrl"]).toHaveBeenCalled();
});

it("should handle pushed authorization requests (PAR) correctly", async () => {
Expand All @@ -4452,11 +4464,11 @@ ca/T0LLtgmbMmxSv/MmzIg==
parRequestCalled = true;
}
});

const secret = await generateSecret(32);
const transactionStore = new TransactionStore({ secret });
const sessionStore = new StatelessSessionStore({ secret });

const authClient = new AuthClient({
transactionStore,
sessionStore,
Expand All @@ -4471,33 +4483,41 @@ ca/T0LLtgmbMmxSv/MmzIg==
},
fetch: mockFetch
});

await authClient.startInteractiveLogin();

// Verify that PAR was used
expect(parRequestCalled).toBe(true);
});

it("should save the transaction state with correct values", async () => {
const authClient = await createAuthClient();
const returnTo = "/custom-path";

// Instead of mocking the oauth functions, we'll just check the structure of the transaction state
const originalSave = authClient['transactionStore'].save;
authClient['transactionStore'].save = vi.fn(async (cookies, transactionState) => {
expect(transactionState).toEqual(expect.objectContaining({
nonce: expect.any(String),
codeVerifier: expect.any(String),
responseType: "code",
state: expect.any(String),
returnTo: "https://example.com/custom-path"
}));
return originalSave.call(authClient['transactionStore'], cookies, transactionState);
});
const originalSave = authClient["transactionStore"].save;
authClient["transactionStore"].save = vi.fn(
async (cookies, transactionState) => {
expect(transactionState).toEqual(
expect.objectContaining({
nonce: expect.any(String),
codeVerifier: expect.any(String),
responseType: "code",
state: expect.any(String),
returnTo: "https://example.com/custom-path"
})
);
return originalSave.call(
authClient["transactionStore"],
cookies,
transactionState
);
}
);

await authClient.startInteractiveLogin({ returnTo });
expect(authClient['transactionStore'].save).toHaveBeenCalled();

expect(authClient["transactionStore"].save).toHaveBeenCalled();
});

it("should merge configuration authorizationParameters with method arguments", async () => {
Expand All @@ -4509,13 +4529,13 @@ ca/T0LLtgmbMmxSv/MmzIg==
audience: configAudience
}
});

const methodScope = "openid profile email custom_scope";
const methodAudience = "https://custom-api.example.com";

// Spy on the authorizationUrl method to verify the passed params
const originalAuthorizationUrl = authClient['authorizationUrl'];
authClient['authorizationUrl'] = vi.fn(async (params) => {
const originalAuthorizationUrl = authClient["authorizationUrl"];
authClient["authorizationUrl"] = vi.fn(async (params) => {
// Method's authorization parameters should override config
expect(params.get("audience")).toBe(methodAudience);
expect(params.get("scope")).toBe(methodScope);
Expand All @@ -4528,14 +4548,14 @@ ca/T0LLtgmbMmxSv/MmzIg==
audience: methodAudience
}
});
expect(authClient['authorizationUrl']).toHaveBeenCalled();

expect(authClient["authorizationUrl"]).toHaveBeenCalled();
});

// Add tests for handleLogin method
it("should create correct options in handleLogin with returnTo parameter", async () => {
const authClient = await createAuthClient();

// Mock startInteractiveLogin to check what options are passed to it
const originalStartInteractiveLogin = authClient.startInteractiveLogin;
authClient.startInteractiveLogin = vi.fn(async (options) => {
Expand All @@ -4546,19 +4566,21 @@ ca/T0LLtgmbMmxSv/MmzIg==
return originalStartInteractiveLogin.call(authClient, options);
});

const reqUrl = new URL("https://example.com/auth/login?foo=bar&returnTo=custom-return");
const reqUrl = new URL(
"https://example.com/auth/login?foo=bar&returnTo=custom-return"
);
const req = new NextRequest(reqUrl, { method: "GET" });

await authClient.handleLogin(req);

expect(authClient.startInteractiveLogin).toHaveBeenCalled();
});

it("should handle PAR correctly in handleLogin by not forwarding params", async () => {
const authClient = await createAuthClient({
pushedAuthorizationRequests: true
});

// Mock startInteractiveLogin to check what options are passed to it
const originalStartInteractiveLogin = authClient.startInteractiveLogin;
authClient.startInteractiveLogin = vi.fn(async (options) => {
Expand All @@ -4569,11 +4591,13 @@ ca/T0LLtgmbMmxSv/MmzIg==
return originalStartInteractiveLogin.call(authClient, options);
});

const reqUrl = new URL("https://example.com/auth/login?foo=bar&returnTo=custom-return");
const reqUrl = new URL(
"https://example.com/auth/login?foo=bar&returnTo=custom-return"
);
const req = new NextRequest(reqUrl, { method: "GET" });

await authClient.handleLogin(req);

expect(authClient.startInteractiveLogin).toHaveBeenCalled();
});
});
Expand Down
Loading
Loading