Skip to content

Commit

Permalink
[SDK-4719] Back-Channel Logout (#1590)
Browse files Browse the repository at this point in the history
  • Loading branch information
adamjmcgrath authored Dec 4, 2023
2 parents d902d2c + a198e49 commit 3acee10
Show file tree
Hide file tree
Showing 36 changed files with 1,010 additions and 87 deletions.
75 changes: 62 additions & 13 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- [Add a signup handler](#add-a-signup-handler)
- [Use with Base Path and Internationalized Routing](#use-with-base-path-and-internationalized-routing)
- [Use a custom session store](#use-a-custom-session-store)
- [Back-Channel Logout](#back-channel-logout)

See also the [example app](./example-app).

Expand Down Expand Up @@ -505,25 +506,73 @@ class Store implements SessionStore {
}
}

let auth0;

export default () => {
if (!auth0) {
auth0 = initAuth0({
session: {
store: new Store()
}
});
export default initAuth0({
session: {
store: new Store()
}
return auth0;
};
});
```

Then use your instance wherever you use the server methods of the SDK.

```ts
// /pages/api/auth/[auth0].js
import getAuth0 from '../../../lib/auth0';
import auth0 from '../../../lib/auth0';

export default auth0.handleAuth();
```

### Back-Channel Logout

Back-Channel Logout requires a session store, so you'll need to create your own instance of the SDK in code and pass an instance of your session store to the SDK's configuration:

```js
// lib/auth0.ts
import { initAuth0 } from '@auth0/nextjs-auth0';

export default getAuth0().handleAuth();
export default initAuth0({
backChannelLogout: {
store: new Store() // See "Use a custom session store" for how to define a Store class.
}
});
```

If you are already using a session store, you can just reuse that:

```js
// lib/auth0.ts
import { initAuth0 } from '@auth0/nextjs-auth0';

export default initAuth0({
session: {
store: new Store()
},
backchannelLogout: true
});
```

Once you've enabled the `backchannelLogout` option, `handleAuth` will create a `/api/auth/backchannel-logout` POST handler.

#### Pages Router

```ts
// /pages/api/auth/[auth0].js
import auth0 from '../../../lib/auth0';

export default auth0.handleAuth();
```

#### App Router

```ts
// /app/api/auth/[auth0]/route.js
import auth0 from '../../../lib/auth0';

const handler = auth0.handleAuth();

// For Back-Channel Logout you need to export a GET and a POST handler.
export { handler as GET, handler as POST };
```

Then configure your tenant following [these instructions](https://auth0.com/docs/authenticate/login/logout/back-channel-logout/configure-back-channel-logout#configure-auth0).
Your "OpenID Connect Back-Channel Logout URI" will be `{YOUR_AUTH0_BASE_URL}/api/auth/backchannel-logout`.
1 change: 1 addition & 0 deletions jest-edge.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module.exports = {
'**/tests/handlers/logout.test.ts',
'**/tests/handlers/callback.test.ts',
'**/tests/handlers/profile.test.ts',
'**/tests/handlers/backchannel-logout.test.ts',
'**/tests/http/auth0-next-request.test.ts',
'**/tests/http/auth0-next-response.test.ts',
'**/tests/helpers/with-middleware-auth-required.test.ts',
Expand Down
6 changes: 6 additions & 0 deletions src/auth0-session/client/abstract-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ export interface AuthorizationParameters {
[key: string]: unknown;
}

export type IssuerMetadata = {
issuer: string;
jwks_uri?: string;
};

export abstract class AbstractClient {
constructor(protected config: Config, protected telemetry: Telemetry) {}
abstract authorizationUrl(parameters: Record<string, unknown>): Promise<string>;
Expand All @@ -103,6 +108,7 @@ export abstract class AbstractClient {
abstract generateRandomCodeVerifier(): string;
abstract generateRandomNonce(): string;
abstract calculateCodeChallenge(codeVerifier: string): Promise<string> | string;
abstract getIssuerMetadata(): Promise<IssuerMetadata>;
}

export type GetClient = (config: Config) => Promise<AbstractClient>;
5 changes: 5 additions & 0 deletions src/auth0-session/client/edge-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ export class EdgeClient extends AbstractClient {
calculateCodeChallenge(codeVerifier: string): Promise<string> {
return oauth.calculatePKCECodeChallenge(codeVerifier);
}

async getIssuerMetadata(): Promise<oauth.AuthorizationServer> {
const [as] = await this.getClient();
return as;
}
}

export const clientGetter = (telemetry: Telemetry): ((config: Config) => Promise<EdgeClient>) => {
Expand Down
8 changes: 7 additions & 1 deletion src/auth0-session/client/node-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
EndSessionParameters,
errors,
generators,
Issuer
Issuer,
IssuerMetadata
} from 'openid-client';
import { ApplicationError, DiscoveryError, EscapedError, IdentityProviderError, UserInfoError } from '../utils/errors';
import { createPrivateKey } from 'crypto';
Expand Down Expand Up @@ -236,6 +237,11 @@ export class NodeClient extends AbstractClient {
calculateCodeChallenge(codeVerifier: string): string {
return generators.codeChallenge(codeVerifier);
}

async getIssuerMetadata(): Promise<IssuerMetadata> {
const { issuer } = await this.getClient();
return issuer.metadata;
}
}

export const clientGetter = (telemetry: Telemetry): ((config: Config) => Promise<NodeClient>) => {
Expand Down
24 changes: 24 additions & 0 deletions src/auth0-session/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,30 @@ export interface Config {
* cookie (Setting SameSite=Strict for example).
*/
transactionCookie: Omit<CookieConfig, 'transient' | 'httpOnly'> & { name: string };

/**
* Set to `true` to enable Back-Channel Logout in your application.
*
* On receipt of a Logout Token the backchannelLogout webhook will store the token, then on any
* subsequent requests, will check the store for a Logout Token that corresponds to the
* current session. If it finds one, it will log the user out.
*
* In order for this to work you need to specify a {@link BackchannelLogoutOptions.store},
* or you can reuse {@link SessionConfigParams.store} if you are using one already.
*
* See: https://openid.net/specs/openid-connect-backchannel-1_0.html
*/
backchannelLogout: boolean | BackchannelLogoutOptions;
}

export interface BackchannelLogoutOptions {
/**
* Used to store Back-Channel Logout entries, you can specify a separate store
* for this or just reuse {@link SessionConfig.store} if you are using one already.
*
* The store should have `get`, `set` and `destroy` methods.
*/
store: SessionStore<any>;
}

/**
Expand Down
21 changes: 19 additions & 2 deletions src/auth0-session/get-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,18 @@ const paramsSchema = Joi.object({
.default(7 * 24 * 60 * 60), // 7 days,
autoSave: Joi.boolean().optional().default(true),
name: Joi.string().token().optional().default('appSession'),
store: Joi.object().optional(),
store: Joi.object()
.optional()
.when(Joi.ref('/backchannelLogout'), {
not: false,
then: Joi.when('/backchannelLogout.store', {
not: Joi.exist(),
then: Joi.object().required().messages({
// eslint-disable-next-line max-len
'any.required': `Back-Channel Logout requires a "backchannelLogout.store" (you can also reuse "session.store" if you have stateful sessions).`
})
})
}),
genId: Joi.function().maxArity(2).when(Joi.ref('store'), { then: Joi.required() }),
storeIDToken: Joi.boolean().optional().default(true),
cookie: Joi.object({
Expand Down Expand Up @@ -176,7 +187,13 @@ const paramsSchema = Joi.object({
path: Joi.string().uri({ relativeOnly: true }).default(Joi.ref('/session.cookie.transient'))
})
.default()
.unknown(false)
.unknown(false),
backchannelLogout: Joi.alternatives([
Joi.object({
store: Joi.object().optional()
}),
Joi.boolean()
]).default(false)
});

export type DeepPartial<T> = {
Expand Down
85 changes: 85 additions & 0 deletions src/auth0-session/handlers/backchannel-logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Auth0Request, Auth0Response } from '../http';
import { Config, GetConfig } from '../config';
import { GetClient } from '../client/abstract-client';
import getLogoutTokenVerifier from '../utils/logout-token-verifier';
import { BackchannelLogoutError } from '../utils/errors';
import { JWTPayload } from 'jose';

const getStore = (config: Config) => {
const {
session: { store },
backchannelLogout
} = config;
return typeof backchannelLogout === 'boolean' ? store! : backchannelLogout.store;

Check warning on line 13 in src/auth0-session/handlers/backchannel-logout.ts

View workflow job for this annotation

GitHub Actions / Lint Code

Forbidden non-null assertion
};

export type HandleBackchannelLogout = (req: Auth0Request, res: Auth0Response) => Promise<void>;

export default function backchannelLogoutHandlerFactory(
getConfig: GetConfig,
getClient: GetClient
): HandleBackchannelLogout {
const getConfigFn = typeof getConfig === 'function' ? getConfig : () => getConfig;
const verifyLogoutToken = getLogoutTokenVerifier();
return async (req, res) => {
const config = await getConfigFn(req);
const client = await getClient(config);
res.setHeader('cache-control', 'no-store');
const body = new URLSearchParams(await req.getBody());
const logoutToken = body.get('logout_token');
if (!logoutToken) {
throw new BackchannelLogoutError('invalid_request', 'Missing Logout Token');
}
let token: JWTPayload;
try {
token = await verifyLogoutToken(logoutToken, config, await client.getIssuerMetadata());
} catch (e) {
throw new BackchannelLogoutError('invalid_request', e.message);
}
const {
clientID,
session: { absoluteDuration, rolling: rollingEnabled, rollingDuration }
} = config;
const store = getStore(config);
const maxAge =
(rollingEnabled
? Math.min(absoluteDuration as number, rollingDuration as number)
: (absoluteDuration as number)) * 1000;
const now = (Date.now() / 1000) | 0;
const payload = {
header: { iat: now, uat: now, exp: now + maxAge, maxAge },
data: {}
};
try {
const { sid, sub } = token;
await Promise.all([
sid && store.set(`sid|${clientID}|${sid}`, payload),
sub && store.set(`sub|${clientID}|${sub}`, payload)
]);
} catch (e) {
throw new BackchannelLogoutError('application_error', e.message);
}
res.send204();
};
}

export type IsLoggedOut = (user: { [key: string]: any }, config: Config) => Promise<boolean>;

export const isLoggedOut: IsLoggedOut = async (user, config) => {
const { clientID } = config;
const store = getStore(config);
const { sid, sub } = user;
const [logoutSid, logoutSub] = await Promise.all([
store.get(`sid|${clientID}|${sid}`),
store.get(`sub|${clientID}|${sub}`)
]);
return !!(logoutSid || logoutSub);
};

export type DeleteSub = (sub: string, config: Config) => Promise<void>;

export const deleteSub: DeleteSub = async (sub, config) => {
const { clientID } = config;
const store = getStore(config);
await store.delete(`sub|${clientID}|${sub}`);
};
4 changes: 4 additions & 0 deletions src/auth0-session/http/auth0-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@ export default abstract class Auth0Response<Res = any> extends Auth0ResponseCook
}

public abstract redirect(location: string, status?: number): void;

public abstract send204(): void;

public abstract setHeader(name: string, value: string): void;
}
9 changes: 9 additions & 0 deletions src/auth0-session/http/node-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,13 @@ export default class NodeResponse<T extends ServerResponse = ServerResponse> ext
});
this.res.end(htmlSafe(location));
}

public send204(): void {
this.res.statusCode = 204;
this.res.end();
}

public setHeader(name: string, value: string): void {
this.res.setHeader(name, value);
}
}
8 changes: 8 additions & 0 deletions src/auth0-session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,13 @@ export { get as getConfig, ConfigParameters, DeepPartial } from './get-config';
export { default as loginHandler, HandleLogin } from './handlers/login';
export { default as logoutHandler, HandleLogout } from './handlers/logout';
export { default as callbackHandler, CallbackOptions, AfterCallback, HandleCallback } from './handlers/callback';
export {
default as backchannelLogoutHandler,
HandleBackchannelLogout,
isLoggedOut,
IsLoggedOut,
DeleteSub,
deleteSub
} from './handlers/backchannel-logout';
export { TokenEndpointResponse, AbstractClient, Telemetry } from './client/abstract-client';
export { SessionCache } from './session-cache';
8 changes: 8 additions & 0 deletions src/auth0-session/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ export class UserInfoError extends EscapedError {
}
}

export class BackchannelLogoutError extends Error {
constructor(public code: string, public description: string) {
/* c8 ignore next */
super(description);
Object.setPrototypeOf(this, BackchannelLogoutError.prototype);
}
}

// eslint-disable-next-line max-len
// Basic escaping for putting untrusted data directly into the HTML body, per: https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#rule-1-html-encode-before-inserting-untrusted-data-into-html-element-content.
export function htmlSafe(input?: string): string | undefined {
Expand Down
Loading

0 comments on commit 3acee10

Please sign in to comment.