Skip to content
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

[SDK-4719] Back-Channel Logout #1590

Merged
merged 11 commits into from
Dec 4, 2023
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 @@ -99,7 +99,7 @@
return authorizationUrl.toString();
}

async callbackParams(req: Auth0Request, expectedState: string) {

Check warning on line 102 in src/auth0-session/client/edge-client.ts

View workflow job for this annotation

GitHub Actions / Lint Code

Missing return type on function
const [as, client] = await this.getClient();
const url =
req.getMethod().toUpperCase() === 'GET' ? new URL(req.getUrl()) : new URLSearchParams(await req.getBody());
Expand Down Expand Up @@ -172,7 +172,7 @@
this.config.idpLogout &&
(this.config.auth0Logout || (issuerUrl.hostname.match('\\.auth0\\.com$') && this.config.auth0Logout !== false))
) {
const { id_token_hint, post_logout_redirect_uri, ...extraParams } = parameters;

Check warning on line 175 in src/auth0-session/client/edge-client.ts

View workflow job for this annotation

GitHub Actions / Lint Code

'id_token_hint' is assigned a value but never used
const auth0LogoutUrl: URL = new URL(urlJoin(as.issuer, '/v2/logout'));
post_logout_redirect_uri && auth0LogoutUrl.searchParams.set('returnTo', post_logout_redirect_uri);
auth0LogoutUrl.searchParams.set('client_id', this.config.clientID);
Expand Down Expand Up @@ -242,6 +242,11 @@
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 @@
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 @@ -140,7 +141,7 @@
) {
Object.defineProperty(this.client, 'endSessionUrl', {
value(params: EndSessionParameters) {
const { id_token_hint, post_logout_redirect_uri, ...extraParams } = params;

Check warning on line 144 in src/auth0-session/client/node-client.ts

View workflow job for this annotation

GitHub Actions / Lint Code

'id_token_hint' is assigned a value but never used
const parsedUrl = new URL(urlJoin(issuer.metadata.issuer, '/v2/logout'));
parsedUrl.searchParams.set('client_id', config.clientID);
post_logout_redirect_uri && parsedUrl.searchParams.set('returnTo', post_logout_redirect_uri);
Expand All @@ -166,7 +167,7 @@
return client.authorizationUrl(parameters);
}

async callbackParams(req: Auth0Request) {

Check warning on line 170 in src/auth0-session/client/node-client.ts

View workflow job for this annotation

GitHub Actions / Lint Code

Missing return type on function
const client = await this.getClient();
const obj: CallbackParamsType = client.callbackParams({
method: req.getMethod(),
Expand Down Expand Up @@ -236,6 +237,11 @@
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 @@ -9,7 +9,7 @@
super(res);
}

public setCookie(name: string, value: string, options?: CookieSerializeOptions) {

Check warning on line 12 in src/auth0-session/http/node-response.ts

View workflow job for this annotation

GitHub Actions / Lint Code

Missing return type on function
let cookies = this.res.getHeader('Set-Cookie') || [];
if (!Array.isArray(cookies)) {
cookies = [cookies as string];
Expand All @@ -30,4 +30,13 @@
});
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
Loading