Skip to content

Commit 4c55597

Browse files
authored
fix(server): mobile oauth login (immich-app#13474)
1 parent 7e49b0c commit 4c55597

File tree

3 files changed

+60
-10
lines changed

3 files changed

+60
-10
lines changed

e2e/src/api/specs/oauth.e2e-spec.ts

+50-2
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,17 @@ const authServer = {
1717
external: 'http://127.0.0.1:3000',
1818
};
1919

20+
const mobileOverrideRedirectUri = 'https://photos.immich.app/oauth/mobile-redirect';
21+
2022
const redirect = async (url: string, cookies?: string[]) => {
2123
const { headers } = await request(url)
2224
.get('/')
2325
.set('Cookie', cookies || []);
2426
return { cookies: (headers['set-cookie'] as unknown as string[]) || [], location: headers.location };
2527
};
2628

27-
const loginWithOAuth = async (sub: OAuthUser | string) => {
28-
const { url } = await startOAuth({ oAuthConfigDto: { redirectUri: `${baseUrl}/auth/login` } });
29+
const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) => {
30+
const { url } = await startOAuth({ oAuthConfigDto: { redirectUri: redirectUri ?? `${baseUrl}/auth/login` } });
2931

3032
// login
3133
const response1 = await redirect(url.replace(authServer.internal, authServer.external));
@@ -255,4 +257,50 @@ describe(`/oauth`, () => {
255257
});
256258
});
257259
});
260+
261+
describe('mobile redirect override', () => {
262+
beforeAll(async () => {
263+
await setupOAuth(admin.accessToken, {
264+
enabled: true,
265+
clientId: OAuthClient.DEFAULT,
266+
clientSecret: OAuthClient.DEFAULT,
267+
buttonText: 'Login with Immich',
268+
storageLabelClaim: 'immich_username',
269+
mobileOverrideEnabled: true,
270+
mobileRedirectUri: mobileOverrideRedirectUri,
271+
});
272+
});
273+
274+
it('should return the mobile redirect uri', async () => {
275+
const { status, body } = await request(app)
276+
.post('/oauth/authorize')
277+
.send({ redirectUri: 'app.immich:///oauth-callback' });
278+
expect(status).toBe(201);
279+
expect(body).toEqual({ url: expect.stringContaining(`${authServer.internal}/auth?`) });
280+
281+
const params = new URL(body.url).searchParams;
282+
expect(params.get('client_id')).toBe('client-default');
283+
expect(params.get('response_type')).toBe('code');
284+
expect(params.get('redirect_uri')).toBe(mobileOverrideRedirectUri);
285+
expect(params.get('state')).toBeDefined();
286+
});
287+
288+
it('should auto register the user by default', async () => {
289+
const url = await loginWithOAuth('oauth-mobile-override', 'app.immich:///oauth-callback');
290+
expect(url).toEqual(expect.stringContaining(mobileOverrideRedirectUri));
291+
292+
// simulate redirecting back to mobile app
293+
const redirectUri = url.replace(mobileOverrideRedirectUri, 'app.immich:///oauth-callback');
294+
295+
const { status, body } = await request(app).post('/oauth/callback').send({ url: redirectUri });
296+
expect(status).toBe(201);
297+
expect(body).toMatchObject({
298+
accessToken: expect.any(String),
299+
isAdmin: false,
300+
name: 'OAuth User',
301+
userEmail: '[email protected]',
302+
userId: expect.any(String),
303+
});
304+
});
305+
});
258306
});

e2e/src/setup/auth-server.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || wi
5050
const setup = async () => {
5151
const { privateKey, publicKey } = await generateKeyPair('RS256');
5252

53+
const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect'];
5354
const port = 3000;
5455
const host = '0.0.0.0';
5556
const oidc = new Provider(`http://${host}:${port}`, {
@@ -86,22 +87,22 @@ const setup = async () => {
8687
{
8788
client_id: OAuthClient.DEFAULT,
8889
client_secret: OAuthClient.DEFAULT,
89-
redirect_uris: ['http://127.0.0.1:2285/auth/login'],
90+
redirect_uris: redirectUris,
9091
grant_types: ['authorization_code'],
9192
response_types: ['code'],
9293
},
9394
{
9495
client_id: OAuthClient.RS256_TOKENS,
9596
client_secret: OAuthClient.RS256_TOKENS,
96-
redirect_uris: ['http://127.0.0.1:2285/auth/login'],
97+
redirect_uris: redirectUris,
9798
grant_types: ['authorization_code'],
9899
id_token_signed_response_alg: 'RS256',
99100
jwks: { keys: [await exportJWK(publicKey)] },
100101
},
101102
{
102103
client_id: OAuthClient.RS256_PROFILE,
103104
client_secret: OAuthClient.RS256_PROFILE,
104-
redirect_uris: ['http://127.0.0.1:2285/auth/login'],
105+
redirect_uris: redirectUris,
105106
grant_types: ['authorization_code'],
106107
userinfo_signed_response_alg: 'RS256',
107108
jwks: { keys: [await exportJWK(publicKey)] },

server/src/services/auth.service.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,13 @@ export class AuthService extends BaseService {
188188
throw new BadRequestException('OAuth is not enabled');
189189
}
190190

191-
const url = await this.oauthRepository.authorize(oauth, dto.redirectUri);
191+
const url = await this.oauthRepository.authorize(oauth, this.resolveRedirectUri(oauth, dto.redirectUri));
192192
return { url };
193193
}
194194

195195
async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) {
196196
const { oauth } = await this.getConfig({ withCache: false });
197-
const profile = await this.oauthRepository.getProfile(oauth, dto.url, this.normalize(oauth, dto.url.split('?')[0]));
197+
const profile = await this.oauthRepository.getProfile(oauth, dto.url, this.resolveRedirectUri(oauth, dto.url));
198198
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth;
199199
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
200200
let user = await this.userRepository.getByOAuthId(profile.sub);
@@ -257,7 +257,7 @@ export class AuthService extends BaseService {
257257
const { sub: oauthId } = await this.oauthRepository.getProfile(
258258
oauth,
259259
dto.url,
260-
this.normalize(oauth, dto.url.split('?')[0]),
260+
this.resolveRedirectUri(oauth, dto.url),
261261
);
262262
const duplicate = await this.userRepository.getByOAuthId(oauthId);
263263
if (duplicate && duplicate.id !== auth.user.id) {
@@ -369,10 +369,11 @@ export class AuthService extends BaseService {
369369
return options.isValid(value) ? (value as T) : options.default;
370370
}
371371

372-
private normalize(
372+
private resolveRedirectUri(
373373
{ mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean },
374-
redirectUri: string,
374+
url: string,
375375
) {
376+
const redirectUri = url.split('?')[0];
376377
const isMobile = redirectUri.startsWith('app.immich:/');
377378
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
378379
return mobileRedirectUri;

0 commit comments

Comments
 (0)