feat: API support for OpenID Connect authentication#2474
Draft
michaelhthomas wants to merge 10 commits intoseerr-team:developfrom
Draft
feat: API support for OpenID Connect authentication#2474michaelhthomas wants to merge 10 commits intoseerr-team:developfrom
michaelhthomas wants to merge 10 commits intoseerr-team:developfrom
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
server/routes/auth.test.ts
Outdated
Check failure
Code scanning / CodeQL
Missing regular expression anchor
Comment on lines
652
to
716
| authRoutes.get('/oidc/login/:slug', async (req, res, next) => { | ||
| const settings = getSettings(); | ||
| const provider = settings.oidc.providers.find( | ||
| (p) => p.slug === req.params.slug | ||
| ); | ||
|
|
||
| if (!settings.main.oidcLogin || !provider) { | ||
| return next({ | ||
| status: 403, | ||
| message: 'OpenID Connect sign-in is disabled.', | ||
| }); | ||
| } | ||
|
|
||
| const config = await openIdClient.discovery( | ||
| new URL(provider.issuerUrl), | ||
| provider.clientId, | ||
| provider.clientSecret | ||
| ); | ||
|
|
||
| const code_verifier = openIdClient.randomPKCECodeVerifier(); | ||
| const code_challenge = await openIdClient.calculatePKCECodeChallenge( | ||
| code_verifier | ||
| ); | ||
| res.cookie('oidc-code-verifier', code_verifier, { | ||
| maxAge: 60000, | ||
| httpOnly: true, | ||
| secure: req.protocol === 'https', | ||
| }); | ||
|
|
||
| const callbackUrl = new URL( | ||
| `/login`, | ||
| settings.main.applicationUrl ?? `${req.protocol}://${req.headers.host}` | ||
| ); | ||
| callbackUrl.searchParams.set('provider', provider.slug); | ||
| callbackUrl.searchParams.set('callback', 'true'); | ||
|
|
||
| const parameters: Record<string, string> = { | ||
| redirect_uri: callbackUrl.toString(), | ||
| scope: provider.scopes ?? 'openid profile email', | ||
| code_challenge, | ||
| code_challenge_method: 'S256', | ||
| }; | ||
|
|
||
| /** | ||
| * We cannot be sure the server supports PKCE so we're going to use state too. | ||
| * Use of PKCE is backwards compatible even if the AS doesn't support it which | ||
| * is why we're using it regardless. Like PKCE, random state must be generated | ||
| * for every redirect to the authorization_endpoint. | ||
| */ | ||
| if (!config.serverMetadata().supportsPKCE()) { | ||
| const state = openIdClient.randomState(); | ||
| parameters.state = state; | ||
| res.cookie('oidc-state', state, { | ||
| maxAge: 60000, | ||
| httpOnly: true, | ||
| secure: req.protocol === 'https', | ||
| }); | ||
| } | ||
|
|
||
| const redirectUrl = openIdClient.buildAuthorizationUrl(config, parameters); | ||
|
|
||
| return res.status(200).json({ | ||
| redirectUrl, | ||
| }); | ||
| }); |
Check failure
Code scanning / CodeQL
Missing rate limiting
Comment on lines
718
to
902
| authRoutes.get('/oidc/callback/:slug', async (req, res, next) => { | ||
| const settings = getSettings(); | ||
| const provider = settings.oidc.providers.find( | ||
| (p) => p.slug === req.params.slug | ||
| ); | ||
|
|
||
| if (!settings.main.oidcLogin || !provider) { | ||
| return next({ | ||
| status: 403, | ||
| message: 'OpenID Connect sign-in is disabled', | ||
| }); | ||
| } | ||
|
|
||
| try { | ||
| const config = await openIdClient.discovery( | ||
| new URL(provider.issuerUrl), | ||
| provider.clientId, | ||
| provider.clientSecret | ||
| ); | ||
|
|
||
| const pkceCodeVerifier = req.cookies['oidc-code-verifier']; | ||
| const expectedState = req.cookies['oidc-state']; | ||
|
|
||
| const tokens = await openIdClient.authorizationCodeGrant( | ||
| config, | ||
| new URL(req.url, `${req.protocol}://${req.hostname}`), | ||
| { | ||
| pkceCodeVerifier, | ||
| expectedState, | ||
| } | ||
| ); | ||
|
|
||
| const claims = tokens.claims(); | ||
| if (claims == null) { | ||
| logger.info('Failed OIDC login attempt', { | ||
| cause: | ||
| 'Missing ID token in response. Provider does not support OpenID Connect.', | ||
| ip: req.ip, | ||
| provider: provider.name, | ||
| }); | ||
|
|
||
| return next({ | ||
| status: 400, | ||
| message: 'Invalid token response', | ||
| }); | ||
| } | ||
|
|
||
| const requiredClaims = (provider.requiredClaims ?? '') | ||
| .split(' ') | ||
| .filter((s) => !!s); | ||
|
|
||
| const userInfo = await openIdClient.fetchUserInfo( | ||
| config, | ||
| tokens.access_token, | ||
| claims.sub | ||
| ); | ||
|
|
||
| const fullUserInfo = { ...claims, ...userInfo }; | ||
|
|
||
| // Validate that user meets required claims | ||
| const hasRequiredClaims = requiredClaims.every((claim) => { | ||
| const value = userInfo[claim]; | ||
| return value === true; | ||
| }); | ||
|
|
||
| if (!hasRequiredClaims) { | ||
| logger.info('Failed OIDC login attempt', { | ||
| cause: 'Failed to validate required claims', | ||
| ip: req.ip, | ||
| requiredClaims: provider.requiredClaims, | ||
| }); | ||
| return next({ | ||
| status: 403, | ||
| message: 'Insufficient permissions', | ||
| }); | ||
| } | ||
|
|
||
| // Map identifier to linked account | ||
| const userRepository = getRepository(User); | ||
| const linkedAccountsRepository = getRepository(LinkedAccount); | ||
|
|
||
| const linkedAccount = await linkedAccountsRepository.findOne({ | ||
| relations: { | ||
| user: true, | ||
| }, | ||
| where: { | ||
| provider: provider.slug, | ||
| sub: fullUserInfo.sub, | ||
| }, | ||
| }); | ||
| let user = linkedAccount?.user; | ||
|
|
||
| // If there is already a user logged in, and no linked account, link the account. | ||
| if (req.user != null && linkedAccount == null) { | ||
| const linkedAccount = new LinkedAccount({ | ||
| user: req.user, | ||
| provider: provider.slug, | ||
| sub: fullUserInfo.sub, | ||
| username: fullUserInfo.preferred_username ?? req.user.displayName, | ||
| }); | ||
|
|
||
| await linkedAccountsRepository.save(linkedAccount); | ||
| return res | ||
| .status(200) | ||
| .json({ status: 'ok', to: '/profile/settings/linked-accounts' }); | ||
| } | ||
|
|
||
| // Create user if one doesn't already exist | ||
| if (!user && fullUserInfo.email != null && provider.newUserLogin) { | ||
| // Check if a user with this email already exists | ||
| const existingUser = await userRepository.findOne({ | ||
| where: { email: fullUserInfo.email }, | ||
| }); | ||
|
|
||
| if (existingUser) { | ||
| // If a user with the email exists, throw a 409 Conflict error | ||
| return next({ | ||
| status: 409, | ||
| message: 'A user with this email address already exists.', | ||
| }); | ||
| } | ||
|
|
||
| logger.info(`Creating user for ${fullUserInfo.email}`, { | ||
| ip: req.ip, | ||
| email: fullUserInfo.email, | ||
| }); | ||
|
|
||
| const avatar = | ||
| fullUserInfo.picture ?? | ||
| gravatarUrl(fullUserInfo.email, { default: 'mm', size: 200 }); | ||
| user = new User({ | ||
| avatar: avatar, | ||
| username: fullUserInfo.preferred_username, | ||
| email: fullUserInfo.email, | ||
| permissions: settings.main.defaultPermissions, | ||
| plexToken: '', | ||
| userType: UserType.LOCAL, | ||
| }); | ||
| await userRepository.save(user); | ||
|
|
||
| const linkedAccount = new LinkedAccount({ | ||
| user, | ||
| provider: provider.slug, | ||
| sub: fullUserInfo.sub, | ||
| username: fullUserInfo.preferred_username ?? fullUserInfo.email, | ||
| }); | ||
| await linkedAccountsRepository.save(linkedAccount); | ||
|
|
||
| user.linkedAccounts = [linkedAccount]; | ||
| await userRepository.save(user); | ||
| } | ||
|
|
||
| if (!user) { | ||
| logger.debug('Failed OIDC sign-up attempt', { | ||
| cause: provider.newUserLogin | ||
| ? 'User did not have an account, and was missing an associated email address.' | ||
| : 'User did not have an account, and new user login was disabled.', | ||
| }); | ||
| return next({ | ||
| status: 400, | ||
| message: provider.newUserLogin | ||
| ? 'Unable to create new user account (missing email address)' | ||
| : 'Unable to create new user account (new user login is disabled)', | ||
| }); | ||
| } | ||
|
|
||
| // Set logged in session and return | ||
| if (req.session) { | ||
| req.session.userId = user.id; | ||
| } | ||
|
|
||
| // Success! | ||
| return res.status(200).json({ status: 'ok', to: '/' }); | ||
| } catch (error) { | ||
| logger.error('Failed OIDC login attempt', { | ||
| cause: 'Unknown error', | ||
| ip: req.ip, | ||
| error, | ||
| }); | ||
| return next({ | ||
| status: 500, | ||
| message: 'An unknown error occurred', | ||
| }); | ||
| } | ||
| }); |
Check failure
Code scanning / CodeQL
Missing rate limiting
TypeORM represents the 'date' column type (date only) as a string, not a JS date object. This was causing the reset password expiration check to never activate, since it compares the date (which is a string) with new Date(), which is always truthy.
0e9863e to
8c6741c
Compare
8c6741c to
6c1cf2e
Compare
6c1cf2e to
e4d4d70
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
How Has This Been Tested?
Screenshots / Logs (if applicable)
Checklist:
pnpm buildpnpm i18n:extract