mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat(server): added backchannel logout api endpoint (#26235)
* feat(server): added backchannel logout api endpoint * test(server): fixed e2e tests * fix(server): fixed suggested changes by reviewer * feat(server): created function invalidateOAuth * fix(server): fixed session.repository.sql * test(server): added unit tests for backchannelLogout function * test(server): added e2e tests for oidc backchnnel logout * docs(server): added documentation on backchannel logout url * docs(server): fixed typo * feat(server): minor improvements of the oidc backchannel logout * test(server): fixed tests after merge with main * fix(server): fixed e2e test file * refactor(server): tiny refactor of validateLogoutToken * chore: cleanup * fix: tests * fix: make jwks extractable --------- Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { createRemoteJWKSet, jwtVerify, JWTVerifyGetKey } from 'jose';
|
||||
import {
|
||||
allowInsecureRequests as allowInsecureRequestsExecute,
|
||||
authorizationCodeGrant,
|
||||
@@ -71,12 +72,12 @@ export class OAuthRepository {
|
||||
return client.serverMetadata().end_session_endpoint;
|
||||
}
|
||||
|
||||
async getProfile(
|
||||
async getProfileAndOAuthSid(
|
||||
config: OAuthConfig,
|
||||
url: string,
|
||||
expectedState: string,
|
||||
codeVerifier: string,
|
||||
): Promise<OAuthProfile> {
|
||||
): Promise<{ profile: OAuthProfile; sid?: string }> {
|
||||
const client = await this.getClient(config);
|
||||
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
|
||||
|
||||
@@ -96,7 +97,15 @@ export class OAuthRepository {
|
||||
throw new Error('Unexpected profile response, no `sub`');
|
||||
}
|
||||
|
||||
return profile;
|
||||
let sid: string | undefined;
|
||||
if (tokens.id_token) {
|
||||
const claims = tokens.claims();
|
||||
if (typeof claims?.sid === 'string') {
|
||||
sid = claims.sid;
|
||||
}
|
||||
}
|
||||
|
||||
return { profile, sid };
|
||||
} catch (error: Error | any) {
|
||||
if (error.message.includes('unexpected JWT alg received')) {
|
||||
this.logger.warn(
|
||||
@@ -126,6 +135,59 @@ export class OAuthRepository {
|
||||
};
|
||||
}
|
||||
|
||||
private jwksClients: Map<string, JWTVerifyGetKey> = new Map(); // useful for caching and performnce
|
||||
async validateLogoutToken(config: OAuthConfig, logoutToken: string): Promise<{ sub?: string; sid?: string } | null> {
|
||||
const client = await this.getClient(config);
|
||||
const algorithm = client.clientMetadata().id_token_signed_response_alg ?? 'RS256';
|
||||
let keyOrGetter: Uint8Array | JWTVerifyGetKey;
|
||||
|
||||
try {
|
||||
if (algorithm.startsWith('HS')) {
|
||||
keyOrGetter = new TextEncoder().encode(config.clientSecret);
|
||||
} else {
|
||||
const jwksUri = client.serverMetadata().jwks_uri;
|
||||
if (!jwksUri) {
|
||||
throw new Error('Unable to get JWKS URI');
|
||||
}
|
||||
|
||||
if (!this.jwksClients.has(jwksUri)) {
|
||||
this.jwksClients.set(jwksUri, createRemoteJWKSet(new URL(jwksUri)));
|
||||
}
|
||||
keyOrGetter = this.jwksClients.get(jwksUri) as JWTVerifyGetKey;
|
||||
}
|
||||
|
||||
const { payload } = await jwtVerify(logoutToken, keyOrGetter as any, {
|
||||
issuer: client.serverMetadata().issuer,
|
||||
audience: config.clientId,
|
||||
algorithms: [algorithm],
|
||||
maxTokenAge: '2m',
|
||||
clockTolerance: '5s',
|
||||
});
|
||||
|
||||
// Validate specific Logout Token claims (RFC 8963):
|
||||
// "events" claim must exist and contain the backchannel-logout event
|
||||
const events = payload.events as Record<string, any> | undefined;
|
||||
if (!events || !events['http://schemas.openid.net/event/backchannel-logout']) {
|
||||
throw new Error('Missing backchannel-logout event claim');
|
||||
}
|
||||
|
||||
// "nonce" must not be present
|
||||
if (payload.nonce) {
|
||||
throw new Error('Logout token must not contain a nonce');
|
||||
}
|
||||
|
||||
return {
|
||||
sub: payload.sub,
|
||||
sid: payload.sid as string | undefined,
|
||||
};
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Error validating JWT logout token: ${error.message}`);
|
||||
this.logger.error(error);
|
||||
|
||||
throw new Error('Error validating JWT logout token', { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
private async getClient({
|
||||
issuerUrl,
|
||||
clientId,
|
||||
|
||||
@@ -102,7 +102,7 @@ export class SessionRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID, excludeId: DummyValue.UUID }] })
|
||||
async invalidate({ userId, excludeId }: { userId: string; excludeId?: string }) {
|
||||
async invalidateAll({ userId, excludeId }: { userId: string; excludeId?: string }) {
|
||||
await this.db
|
||||
.deleteFrom('session')
|
||||
.where('userId', '=', userId)
|
||||
@@ -110,6 +110,28 @@ export class SessionRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING, DummyValue.STRING] })
|
||||
async invalidateOAuth({ oauthSid, oauthId }: { oauthSid?: string; oauthId?: string }): Promise<string[]> {
|
||||
let query = this.db.deleteFrom('session').returning('session.id');
|
||||
|
||||
if (oauthSid && oauthId) {
|
||||
query = query
|
||||
.using('user')
|
||||
.whereRef('user.id', '=', 'session.userId')
|
||||
.where('session.oauthSid', '=', oauthSid)
|
||||
.where('user.oauthId', '=', oauthId);
|
||||
} else if (!oauthSid && oauthId) {
|
||||
query = query.using('user').whereRef('user.id', '=', 'session.userId').where('user.oauthId', '=', oauthId);
|
||||
} else if (oauthSid && !oauthId) {
|
||||
query = query.where('session.oauthSid', '=', oauthSid);
|
||||
} else {
|
||||
throw new Error('Invalid arguments: at least one of oauthSid or oauthId must be present');
|
||||
}
|
||||
|
||||
const deletedRows = await query.execute();
|
||||
return deletedRows.map((row) => row.id);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async lockAll(userId: string) {
|
||||
await this.db.updateTable('session').set({ pinExpiresAt: null }).where('userId', '=', userId).execute();
|
||||
|
||||
Reference in New Issue
Block a user