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:
santanoce
2026-04-17 20:45:33 +02:00
committed by GitHub
parent 8afca348ff
commit dbf30b77bf
21 changed files with 558 additions and 47 deletions
+136 -26
View File
@@ -196,6 +196,64 @@ describe(AuthService.name, () => {
});
});
describe('backchannelLogout', () => {
const dto = { logout_token: 'fake-jwt-token' };
it('should throw a Bad Request Exception if OAuth is not enabled', async () => {
await expect(sut.backchannelLogout(dto)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.backchannelLogout(dto)).rejects.toThrow(
'Received backchannel logout request but OAuth is not enabled',
);
});
it('should throw a Bad Request Exception if the logout token validation fails', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.validateLogoutToken.mockRejectedValue(new Error('Token validation failed'));
await expect(sut.backchannelLogout(dto)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.backchannelLogout(dto)).rejects.toThrow('Error backchannel logout: token validation failed');
});
it('should throw a Bad Request Exception if there are no claims in the logout token', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.validateLogoutToken.mockResolvedValue(null);
await expect(sut.backchannelLogout(dto)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.backchannelLogout(dto)).rejects.toThrow('Invalid logout token: no claims found');
});
it('should throw a Bad Request Exception if there is neither the sub nor the sid claim', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.validateLogoutToken.mockResolvedValue({ sub: '', sid: '' });
await expect(sut.backchannelLogout(dto)).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.backchannelLogout(dto)).rejects.toThrow(
'Invalid logout token: it must contain either a sub or a sid claim',
);
});
it('should invalidate the OAuth session(s) if the logout token is valid', async () => {
const claims = { sub: 'fake-sub', sid: 'fake-sid' };
const deletedSessionIds: string[] = ['fake-session-1', 'fake-session-2'];
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.validateLogoutToken.mockResolvedValue(claims);
mocks.session.invalidateOAuth.mockResolvedValue(deletedSessionIds);
mocks.event.emit.mockResolvedValue(void 0);
mocks.event.emit.mockResolvedValue(void 0);
await sut.backchannelLogout(dto);
expect(mocks.session.invalidateOAuth).toHaveBeenCalledWith({
oauthSid: claims.sid,
oauthId: claims.sub,
});
expect(mocks.event.emit).toHaveBeenCalledWith('SessionDelete', { sessionId: 'fake-session-1' });
expect(mocks.event.emit).toHaveBeenCalledWith('SessionDelete', { sessionId: 'fake-session-2' });
});
});
describe('adminSignUp', () => {
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' };
@@ -250,6 +308,7 @@ describe(AuthService.name, () => {
user: UserFactory.create(),
pinExpiresAt: null,
appVersion: null,
oauthSid: null,
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@@ -416,6 +475,7 @@ describe(AuthService.name, () => {
user: UserFactory.create(),
pinExpiresAt: null,
appVersion: null,
oauthSid: null,
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@@ -444,6 +504,7 @@ describe(AuthService.name, () => {
isPendingSyncReset: false,
pinExpiresAt: null,
appVersion: null,
oauthSid: null,
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@@ -466,6 +527,7 @@ describe(AuthService.name, () => {
isPendingSyncReset: false,
pinExpiresAt: null,
appVersion: null,
oauthSid: null,
};
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
@@ -601,7 +663,7 @@ describe(AuthService.name, () => {
it('should not allow auto registering', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
await expect(
sut.callback(
@@ -619,7 +681,7 @@ describe(AuthService.name, () => {
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -639,7 +701,7 @@ describe(AuthService.name, () => {
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -658,7 +720,7 @@ describe(AuthService.name, () => {
const user = UserFactory.create({ oauthId: 'existing-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.user.getByEmail.mockResolvedValueOnce(user);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
@@ -679,7 +741,7 @@ describe(AuthService.name, () => {
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
@@ -698,7 +760,7 @@ describe(AuthService.name, () => {
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauth.getProfile.mockResolvedValue({ sub: 'sub' });
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: { sub: 'sub' } });
await expect(
sut.callback(
@@ -720,12 +782,12 @@ describe(AuthService.name, () => {
it(`should use the mobile redirect override for a url of ${url}`, async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create());
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
expect(mocks.oauth.getProfile).toHaveBeenCalledWith(
expect(mocks.oauth.getProfileAndOAuthSid).toHaveBeenCalledWith(
expect.objectContaining({}),
'http://mobile-redirect?code=abc123',
'xyz789',
@@ -738,7 +800,7 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -753,9 +815,9 @@ describe(AuthService.name, () => {
it('should infer name from given and family names', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(
OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
@@ -774,7 +836,7 @@ describe(AuthService.name, () => {
const profile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
@@ -791,7 +853,9 @@ describe(AuthService.name, () => {
it('should ignore an invalid storage quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 'abc' }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: 'abc' }),
});
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
@@ -808,7 +872,9 @@ describe(AuthService.name, () => {
it('should ignore a negative quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: -5 }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: -5 }),
});
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
@@ -825,7 +891,7 @@ describe(AuthService.name, () => {
it('should set quota for 0 quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 0 }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 0 }) });
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
@@ -842,7 +908,7 @@ describe(AuthService.name, () => {
it('should use a valid storage quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 5 }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 5 }) });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
@@ -864,7 +930,7 @@ describe(AuthService.name, () => {
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.crypto.randomUUID.mockReturnValue(fileId);
mocks.oauth.getProfilePicture.mockResolvedValue({
@@ -891,13 +957,13 @@ describe(AuthService.name, () => {
const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue(
OAuthProfileFactory.create({
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({
sub: user.oauthId,
email: user.email,
picture: 'https://auth.immich.cloud/profiles/1.jpg',
}),
);
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
@@ -914,7 +980,9 @@ describe(AuthService.name, () => {
it('should only allow "admin" and "user" for the role claim', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'foo' }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: 'foo' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
@@ -932,7 +1000,9 @@ describe(AuthService.name, () => {
it('should create an admin user if the role claim is set to admin', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'admin' }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: 'admin' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
@@ -951,7 +1021,9 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' },
});
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ my_role: 'admin' }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ my_role: 'admin' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
@@ -974,7 +1046,7 @@ describe(AuthService.name, () => {
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.update.mockResolvedValue(user);
await sut.link(
@@ -986,13 +1058,36 @@ describe(AuthService.name, () => {
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub });
});
it('should link an account and update the session with the oauthSid', async () => {
const user = UserFactory.create();
const session = SessionFactory.create();
const auth = AuthFactory.from(user).session(session).build();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: { sub: 'sub' },
sid: session.oauthSid ?? undefined,
});
mocks.user.update.mockResolvedValue(user);
mocks.session.update.mockResolvedValue(session);
await sut.link(
auth,
{ url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
);
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: session.oauthSid });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: 'sub' });
});
it('should not link an already linked oauth.sub', async () => {
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.create({ permissions: [] });
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
await expect(
@@ -1015,6 +1110,21 @@ describe(AuthService.name, () => {
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
});
it('should unlink an account and remove the oauthSid from the session', async () => {
const user = UserFactory.create();
const session = SessionFactory.create();
const auth = AuthFactory.from(user).session(session).build();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.session.update.mockResolvedValue(session);
mocks.user.update.mockResolvedValue(user);
await sut.unlink(auth);
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
});
});
describe('setupPinCode', () => {