Skip to content

Commit 5e57a25

Browse files
committed
Add Keycloak OIDC token bridge for PixelUnion API access.
Store encrypted refresh tokens per session and expose POST /api/auth/oidc-token so authenticated clients can exchange them for Keycloak access tokens.
1 parent d8ac7b8 commit 5e57a25

14 files changed

Lines changed: 388 additions & 6 deletions

open-api/immich-openapi-specs.json

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4679,6 +4679,46 @@
46794679
"x-immich-state": "Stable"
46804680
}
46814681
},
4682+
"/auth/oidc-token": {
4683+
"post": {
4684+
"description": "Exchange the stored Keycloak refresh token for a fresh access token for use with PixelUnion APIs.",
4685+
"operationId": "getOidcToken",
4686+
"parameters": [],
4687+
"responses": {
4688+
"200": {
4689+
"content": {
4690+
"application/json": {
4691+
"schema": {
4692+
"$ref": "#/components/schemas/OidcTokenResponseDto"
4693+
}
4694+
}
4695+
},
4696+
"description": ""
4697+
}
4698+
},
4699+
"security": [
4700+
{
4701+
"bearer": []
4702+
},
4703+
{
4704+
"cookie": []
4705+
},
4706+
{
4707+
"api_key": []
4708+
}
4709+
],
4710+
"summary": "Exchange OIDC access token",
4711+
"tags": [
4712+
"Authentication"
4713+
],
4714+
"x-immich-history": [
4715+
{
4716+
"version": "v1",
4717+
"state": "Added"
4718+
}
4719+
]
4720+
}
4721+
},
46824722
"/auth/pin-code": {
46834723
"delete": {
46844724
"description": "Reset the pin code for the current user by providing the account password",
@@ -19559,6 +19599,19 @@
1955919599
],
1956019600
"type": "object"
1956119601
},
19602+
"OidcTokenResponseDto": {
19603+
"properties": {
19604+
"accessToken": {
19605+
"description": "OIDC access token for use with PixelUnion APIs",
19606+
"type": "string"
19607+
},
19608+
"reauth": {
19609+
"description": "Whether the user must re-authenticate via OAuth",
19610+
"type": "boolean"
19611+
}
19612+
},
19613+
"type": "object"
19614+
},
1956219615
"OnThisDayDto": {
1956319616
"properties": {
1956419617
"year": {

server/src/controllers/auth.controller.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
LoginCredentialDto,
1010
LoginResponseDto,
1111
LogoutResponseDto,
12+
OidcTokenResponseDto,
1213
PinCodeChangeDto,
1314
PinCodeResetDto,
1415
PinCodeSetupDto,
@@ -118,6 +119,19 @@ export class AuthController {
118119
return this.service.getAuthStatus(auth);
119120
}
120121

122+
@Post('oidc-token')
123+
@Authenticated()
124+
@HttpCode(HttpStatus.OK)
125+
@Endpoint({
126+
summary: 'Exchange OIDC access token',
127+
description:
128+
'Exchange the stored Keycloak refresh token for a fresh access token for use with PixelUnion APIs.',
129+
history: new HistoryBuilder().added('v1'),
130+
})
131+
getOidcToken(@Auth() auth: AuthDto): Promise<OidcTokenResponseDto> {
132+
return this.service.getOidcToken(auth);
133+
}
134+
121135
@Post('pin-code')
122136
@Authenticated({ permission: Permission.PinCodeCreate })
123137
@HttpCode(HttpStatus.NO_CONTENT)

server/src/dtos/auth.dto.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,11 @@ export class AuthStatusResponseDto {
184184
@ApiPropertyOptional({ description: 'PIN expiration date' })
185185
pinExpiresAt?: string;
186186
}
187+
188+
export class OidcTokenResponseDto {
189+
@ApiPropertyOptional({ description: 'OIDC access token for use with PixelUnion APIs' })
190+
accessToken?: string;
191+
192+
@ApiPropertyOptional({ description: 'Whether the user must re-authenticate via OAuth' })
193+
reauth?: boolean;
194+
}

server/src/repositories/crypto.repository.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Injectable } from '@nestjs/common';
22
import { compareSync, hash } from 'bcrypt';
33
import jwt from 'jsonwebtoken';
4-
import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from 'node:crypto';
4+
import { createCipheriv, createDecipheriv, createHash, createPublicKey, createVerify, randomBytes, randomUUID } from 'node:crypto';
55
import { createReadStream } from 'node:fs';
66

77
@Injectable()
@@ -66,4 +66,21 @@ export class CryptoRepository {
6666
verifyJwt<T = any>(token: string, secret: string): T {
6767
return jwt.verify(token, secret, { algorithms: ['HS256'] }) as T;
6868
}
69+
70+
encryptAesGcm(plaintext: string, key: Buffer): Buffer {
71+
const iv = randomBytes(12);
72+
const cipher = createCipheriv('aes-256-gcm', key, iv);
73+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
74+
const tag = cipher.getAuthTag();
75+
return Buffer.concat([iv, tag, encrypted]);
76+
}
77+
78+
decryptAesGcm(data: Buffer, key: Buffer): string {
79+
const iv = data.subarray(0, 12);
80+
const tag = data.subarray(12, 28);
81+
const encrypted = data.subarray(28);
82+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
83+
decipher.setAuthTag(tag);
84+
return decipher.update(encrypted) + decipher.final('utf8');
85+
}
6986
}

server/src/repositories/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository';
3030
import { OcrRepository } from 'src/repositories/ocr.repository';
3131
import { PartnerRepository } from 'src/repositories/partner.repository';
3232
import { PersonRepository } from 'src/repositories/person.repository';
33+
import { PixelUnionSessionTokenRepository } from 'src/repositories/pixelunion-session-token.repository';
3334
import { PluginRepository } from 'src/repositories/plugin.repository';
3435
import { ProcessRepository } from 'src/repositories/process.repository';
3536
import { PuApiRepository } from 'src/repositories/pu-api.repository';
@@ -85,6 +86,7 @@ export const repositories = [
8586
OcrRepository,
8687
PartnerRepository,
8788
PersonRepository,
89+
PixelUnionSessionTokenRepository,
8890
PluginRepository,
8991
ProcessRepository,
9092
PuApiRepository,

server/src/repositories/oauth.repository.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import {
1111
None,
1212
randomPKCECodeVerifier,
1313
randomState,
14+
refreshTokenGrant,
1415
skipSubjectCheck,
16+
tokenRevocation,
1517
type UserInfoResponse,
1618
} from 'openid-client';
1719
import { OAuthTokenEndpointAuthMethod } from 'src/enum';
@@ -113,6 +115,66 @@ export class OAuthRepository {
113115
}
114116
}
115117

118+
async getProfileWithTokens(
119+
config: OAuthConfig,
120+
url: string,
121+
expectedState: string,
122+
codeVerifier: string,
123+
): Promise<{ profile: OAuthProfile; refreshToken?: string }> {
124+
const client = await this.getClient(config);
125+
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
126+
127+
try {
128+
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
129+
130+
let profile: OAuthProfile;
131+
const tokenClaims = tokens.claims();
132+
if (tokenClaims && 'email' in tokenClaims) {
133+
this.logger.debug('Using ID token claims instead of userinfo endpoint');
134+
profile = tokenClaims as OAuthProfile;
135+
} else {
136+
profile = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck);
137+
}
138+
139+
if (!profile.sub) {
140+
throw new Error('Unexpected profile response, no `sub`');
141+
}
142+
143+
return { profile, refreshToken: tokens.refresh_token };
144+
} catch (error: Error | any) {
145+
if (error.message.includes('unexpected JWT alg received')) {
146+
this.logger.warn(
147+
[
148+
'Algorithm mismatch. Make sure the signing algorithm is set correctly in the OAuth settings.',
149+
'Or, that you have specified a signing key in your OAuth provider.',
150+
].join(' '),
151+
);
152+
}
153+
154+
this.logger.error(`OAuth login failed: ${error.message}`);
155+
this.logger.error(error);
156+
157+
throw new Error('OAuth login failed', { cause: error });
158+
}
159+
}
160+
161+
async refreshAccessToken(
162+
config: OAuthConfig,
163+
refreshToken: string,
164+
): Promise<{ accessToken: string; newRefreshToken?: string }> {
165+
const client = await this.getClient(config);
166+
const tokens = await refreshTokenGrant(client, refreshToken);
167+
return {
168+
accessToken: tokens.access_token!,
169+
newRefreshToken: tokens.refresh_token,
170+
};
171+
}
172+
173+
async revokeToken(config: OAuthConfig, token: string): Promise<void> {
174+
const client = await this.getClient(config);
175+
await tokenRevocation(client, token);
176+
}
177+
116178
async getProfilePicture(url: string) {
117179
const response = await fetch(url);
118180
if (!response.ok) {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { Kysely } from 'kysely';
3+
import { InjectKysely } from 'nestjs-kysely';
4+
import { DB } from 'src/schema';
5+
import { asUuid } from 'src/utils/database';
6+
7+
@Injectable()
8+
export class PixelUnionSessionTokenRepository {
9+
constructor(@InjectKysely() private db: Kysely<DB>) {}
10+
11+
get(sessionId: string) {
12+
return this.db
13+
.selectFrom('pixelunion_session_token')
14+
.select('keycloakRefreshToken')
15+
.where('sessionId', '=', asUuid(sessionId))
16+
.executeTakeFirst();
17+
}
18+
19+
upsert(sessionId: string, keycloakRefreshToken: Buffer) {
20+
return this.db
21+
.insertInto('pixelunion_session_token')
22+
.values({ sessionId, keycloakRefreshToken })
23+
.onConflict((oc) => oc.column('sessionId').doUpdateSet({ keycloakRefreshToken }))
24+
.execute();
25+
}
26+
27+
delete(sessionId: string) {
28+
return this.db.deleteFrom('pixelunion_session_token').where('sessionId', '=', asUuid(sessionId)).execute();
29+
}
30+
}

server/src/schema/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
5656
import { PartnerTable } from 'src/schema/tables/partner.table';
5757
import { PersonAuditTable } from 'src/schema/tables/person-audit.table';
5858
import { PersonTable } from 'src/schema/tables/person.table';
59+
import { PixelUnionSessionTokenTable } from 'src/schema/tables/pixelunion-session-token.table';
5960
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
6061
import { SessionTable } from 'src/schema/tables/session.table';
6162
import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table';
@@ -115,6 +116,7 @@ export class ImmichDatabase {
115116
PartnerTable,
116117
PersonTable,
117118
PersonAuditTable,
119+
PixelUnionSessionTokenTable,
118120
SessionTable,
119121
SharedLinkAssetTable,
120122
SharedLinkTable,
@@ -224,6 +226,8 @@ export interface DB {
224226
person: PersonTable;
225227
person_audit: PersonAuditTable;
226228

229+
pixelunion_session_token: PixelUnionSessionTokenTable;
230+
227231
session: SessionTable;
228232
session_sync_checkpoint: SessionSyncCheckpointTable;
229233

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Kysely, sql } from 'kysely';
2+
3+
export async function up(db: Kysely<any>): Promise<void> {
4+
await sql`CREATE TABLE "pixelunion_session_token" (
5+
"sessionId" uuid NOT NULL,
6+
"keycloakRefreshToken" bytea NOT NULL,
7+
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
8+
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
9+
CONSTRAINT "pixelunion_session_token_pkey" PRIMARY KEY ("sessionId"),
10+
CONSTRAINT "pixelunion_session_token_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "session" ("id") ON UPDATE CASCADE ON DELETE CASCADE
11+
);`.execute(db);
12+
await sql`CREATE OR REPLACE TRIGGER "pixelunion_session_token_updatedAt"
13+
BEFORE UPDATE ON "pixelunion_session_token"
14+
FOR EACH ROW
15+
EXECUTE FUNCTION updated_at();`.execute(db);
16+
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_pixelunion_session_token_updatedAt', '{"type":"trigger","name":"pixelunion_session_token_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"pixelunion_session_token_updatedAt\\"\\n BEFORE UPDATE ON \\"pixelunion_session_token\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
17+
}
18+
19+
export async function down(db: Kysely<any>): Promise<void> {
20+
await sql`DROP TRIGGER "pixelunion_session_token_updatedAt" ON "pixelunion_session_token";`.execute(db);
21+
await sql`DROP TABLE "pixelunion_session_token";`.execute(db);
22+
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_pixelunion_session_token_updatedAt';`.execute(db);
23+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp, UpdateDateColumn } from '@immich/sql-tools';
2+
import { UpdatedAtTrigger } from 'src/decorators';
3+
import { SessionTable } from 'src/schema/tables/session.table';
4+
5+
@Table('pixelunion_session_token')
6+
@UpdatedAtTrigger('pixelunion_session_token_updatedAt')
7+
export class PixelUnionSessionTokenTable {
8+
@ForeignKeyColumn(() => SessionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', primary: true })
9+
sessionId!: string;
10+
11+
@Column({ type: 'bytea' })
12+
keycloakRefreshToken!: Buffer;
13+
14+
@CreateDateColumn()
15+
createdAt!: Generated<Timestamp>;
16+
17+
@UpdateDateColumn()
18+
updatedAt!: Generated<Timestamp>;
19+
}

0 commit comments

Comments
 (0)