mirror of
https://github.com/immich-app/immich.git
synced 2025-01-07 00:50:23 -05:00
fix(server): mobile oauth login (#13474)
This commit is contained in:
parent
7e49b0c875
commit
4c55597478
3 changed files with 60 additions and 10 deletions
|
@ -17,6 +17,8 @@ const authServer = {
|
||||||
external: 'http://127.0.0.1:3000',
|
external: 'http://127.0.0.1:3000',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mobileOverrideRedirectUri = 'https://photos.immich.app/oauth/mobile-redirect';
|
||||||
|
|
||||||
const redirect = async (url: string, cookies?: string[]) => {
|
const redirect = async (url: string, cookies?: string[]) => {
|
||||||
const { headers } = await request(url)
|
const { headers } = await request(url)
|
||||||
.get('/')
|
.get('/')
|
||||||
|
@ -24,8 +26,8 @@ const redirect = async (url: string, cookies?: string[]) => {
|
||||||
return { cookies: (headers['set-cookie'] as unknown as string[]) || [], location: headers.location };
|
return { cookies: (headers['set-cookie'] as unknown as string[]) || [], location: headers.location };
|
||||||
};
|
};
|
||||||
|
|
||||||
const loginWithOAuth = async (sub: OAuthUser | string) => {
|
const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) => {
|
||||||
const { url } = await startOAuth({ oAuthConfigDto: { redirectUri: `${baseUrl}/auth/login` } });
|
const { url } = await startOAuth({ oAuthConfigDto: { redirectUri: redirectUri ?? `${baseUrl}/auth/login` } });
|
||||||
|
|
||||||
// login
|
// login
|
||||||
const response1 = await redirect(url.replace(authServer.internal, authServer.external));
|
const response1 = await redirect(url.replace(authServer.internal, authServer.external));
|
||||||
|
@ -255,4 +257,50 @@ describe(`/oauth`, () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mobile redirect override', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await setupOAuth(admin.accessToken, {
|
||||||
|
enabled: true,
|
||||||
|
clientId: OAuthClient.DEFAULT,
|
||||||
|
clientSecret: OAuthClient.DEFAULT,
|
||||||
|
buttonText: 'Login with Immich',
|
||||||
|
storageLabelClaim: 'immich_username',
|
||||||
|
mobileOverrideEnabled: true,
|
||||||
|
mobileRedirectUri: mobileOverrideRedirectUri,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the mobile redirect uri', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.post('/oauth/authorize')
|
||||||
|
.send({ redirectUri: 'app.immich:///oauth-callback' });
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body).toEqual({ url: expect.stringContaining(`${authServer.internal}/auth?`) });
|
||||||
|
|
||||||
|
const params = new URL(body.url).searchParams;
|
||||||
|
expect(params.get('client_id')).toBe('client-default');
|
||||||
|
expect(params.get('response_type')).toBe('code');
|
||||||
|
expect(params.get('redirect_uri')).toBe(mobileOverrideRedirectUri);
|
||||||
|
expect(params.get('state')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto register the user by default', async () => {
|
||||||
|
const url = await loginWithOAuth('oauth-mobile-override', 'app.immich:///oauth-callback');
|
||||||
|
expect(url).toEqual(expect.stringContaining(mobileOverrideRedirectUri));
|
||||||
|
|
||||||
|
// simulate redirecting back to mobile app
|
||||||
|
const redirectUri = url.replace(mobileOverrideRedirectUri, 'app.immich:///oauth-callback');
|
||||||
|
|
||||||
|
const { status, body } = await request(app).post('/oauth/callback').send({ url: redirectUri });
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
accessToken: expect.any(String),
|
||||||
|
isAdmin: false,
|
||||||
|
name: 'OAuth User',
|
||||||
|
userEmail: 'oauth-mobile-override@immich.app',
|
||||||
|
userId: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -50,6 +50,7 @@ const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || wi
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||||
|
|
||||||
|
const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect'];
|
||||||
const port = 3000;
|
const port = 3000;
|
||||||
const host = '0.0.0.0';
|
const host = '0.0.0.0';
|
||||||
const oidc = new Provider(`http://${host}:${port}`, {
|
const oidc = new Provider(`http://${host}:${port}`, {
|
||||||
|
@ -86,14 +87,14 @@ const setup = async () => {
|
||||||
{
|
{
|
||||||
client_id: OAuthClient.DEFAULT,
|
client_id: OAuthClient.DEFAULT,
|
||||||
client_secret: OAuthClient.DEFAULT,
|
client_secret: OAuthClient.DEFAULT,
|
||||||
redirect_uris: ['http://127.0.0.1:2285/auth/login'],
|
redirect_uris: redirectUris,
|
||||||
grant_types: ['authorization_code'],
|
grant_types: ['authorization_code'],
|
||||||
response_types: ['code'],
|
response_types: ['code'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
client_id: OAuthClient.RS256_TOKENS,
|
client_id: OAuthClient.RS256_TOKENS,
|
||||||
client_secret: OAuthClient.RS256_TOKENS,
|
client_secret: OAuthClient.RS256_TOKENS,
|
||||||
redirect_uris: ['http://127.0.0.1:2285/auth/login'],
|
redirect_uris: redirectUris,
|
||||||
grant_types: ['authorization_code'],
|
grant_types: ['authorization_code'],
|
||||||
id_token_signed_response_alg: 'RS256',
|
id_token_signed_response_alg: 'RS256',
|
||||||
jwks: { keys: [await exportJWK(publicKey)] },
|
jwks: { keys: [await exportJWK(publicKey)] },
|
||||||
|
@ -101,7 +102,7 @@ const setup = async () => {
|
||||||
{
|
{
|
||||||
client_id: OAuthClient.RS256_PROFILE,
|
client_id: OAuthClient.RS256_PROFILE,
|
||||||
client_secret: OAuthClient.RS256_PROFILE,
|
client_secret: OAuthClient.RS256_PROFILE,
|
||||||
redirect_uris: ['http://127.0.0.1:2285/auth/login'],
|
redirect_uris: redirectUris,
|
||||||
grant_types: ['authorization_code'],
|
grant_types: ['authorization_code'],
|
||||||
userinfo_signed_response_alg: 'RS256',
|
userinfo_signed_response_alg: 'RS256',
|
||||||
jwks: { keys: [await exportJWK(publicKey)] },
|
jwks: { keys: [await exportJWK(publicKey)] },
|
||||||
|
|
|
@ -188,13 +188,13 @@ export class AuthService extends BaseService {
|
||||||
throw new BadRequestException('OAuth is not enabled');
|
throw new BadRequestException('OAuth is not enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = await this.oauthRepository.authorize(oauth, dto.redirectUri);
|
const url = await this.oauthRepository.authorize(oauth, this.resolveRedirectUri(oauth, dto.redirectUri));
|
||||||
return { url };
|
return { url };
|
||||||
}
|
}
|
||||||
|
|
||||||
async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) {
|
async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) {
|
||||||
const { oauth } = await this.getConfig({ withCache: false });
|
const { oauth } = await this.getConfig({ withCache: false });
|
||||||
const profile = await this.oauthRepository.getProfile(oauth, dto.url, this.normalize(oauth, dto.url.split('?')[0]));
|
const profile = await this.oauthRepository.getProfile(oauth, dto.url, this.resolveRedirectUri(oauth, dto.url));
|
||||||
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth;
|
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth;
|
||||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||||
let user = await this.userRepository.getByOAuthId(profile.sub);
|
let user = await this.userRepository.getByOAuthId(profile.sub);
|
||||||
|
@ -257,7 +257,7 @@ export class AuthService extends BaseService {
|
||||||
const { sub: oauthId } = await this.oauthRepository.getProfile(
|
const { sub: oauthId } = await this.oauthRepository.getProfile(
|
||||||
oauth,
|
oauth,
|
||||||
dto.url,
|
dto.url,
|
||||||
this.normalize(oauth, dto.url.split('?')[0]),
|
this.resolveRedirectUri(oauth, dto.url),
|
||||||
);
|
);
|
||||||
const duplicate = await this.userRepository.getByOAuthId(oauthId);
|
const duplicate = await this.userRepository.getByOAuthId(oauthId);
|
||||||
if (duplicate && duplicate.id !== auth.user.id) {
|
if (duplicate && duplicate.id !== auth.user.id) {
|
||||||
|
@ -369,10 +369,11 @@ export class AuthService extends BaseService {
|
||||||
return options.isValid(value) ? (value as T) : options.default;
|
return options.isValid(value) ? (value as T) : options.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalize(
|
private resolveRedirectUri(
|
||||||
{ mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean },
|
{ mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean },
|
||||||
redirectUri: string,
|
url: string,
|
||||||
) {
|
) {
|
||||||
|
const redirectUri = url.split('?')[0];
|
||||||
const isMobile = redirectUri.startsWith('app.immich:/');
|
const isMobile = redirectUri.startsWith('app.immich:/');
|
||||||
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
|
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
|
||||||
return mobileRedirectUri;
|
return mobileRedirectUri;
|
||||||
|
|
Loading…
Reference in a new issue