Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
62 changes: 62 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,67 @@ 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 the session cookie attributes either through environment variables or directly in the SDK initialization.

**1. Using Environment Variables:**

Set the desired environment variables in your `.env.local` file or your deployment environment:

```
# .env.local
# ... other variables ...

# Cookie Options
AUTH0_COOKIE_DOMAIN='.example.com' # Set cookie for subdomains
AUTH0_COOKIE_PATH='/app' # Limit cookie to /app path
AUTH0_COOKIE_TRANSIENT=true # Make cookie transient (session-only)
AUTH0_COOKIE_SECURE=true # Recommended for production
AUTH0_COOKIE_SAME_SITE='Lax'
```

The SDK will automatically pick up these values. Note that `httpOnly` is always set to `true` for security reasons and cannot be configured.

**2. Using `Auth0ClientOptions`:**

Configure the options directly when initializing the client:

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

export const auth0 = new Auth0Client({
session: {
cookie: {
domain: '.example.com',
path: '/app',
transient: true,
// httpOnly is always true and cannot be configured
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 ...
});
```

**Session 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`.
* `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`.

> [!INFO]
> Options provided directly in `Auth0ClientOptions` take precedence over environment variables. The `httpOnly` attribute is always `true` regardless of configuration.

> [!INFO]
> The `httpOnly` attribute for the session cookie is always set to `true` for security reasons and cannot be configured via options or environment variables.

## 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
13 changes: 12 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`, and `transient`. If not specified, these can be configured using `AUTH0_COOKIE_*` environment variables. Note: `httpOnly` is always `true`. 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,17 @@ 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_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