Skip to content

feat: API support for OpenID Connect authentication#2474

Draft
michaelhthomas wants to merge 4 commits intoseerr-team:developfrom
michaelhthomas:feat/oidc-login-api
Draft

feat: API support for OpenID Connect authentication#2474
michaelhthomas wants to merge 4 commits intoseerr-team:developfrom
michaelhthomas:feat/oidc-login-api

Conversation

@michaelhthomas
Copy link
Contributor

Description

  • Fixes #XXXX

How Has This Been Tested?

Screenshots / Logs (if applicable)

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy)
  • I have updated the documentation accordingly.
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract
  • Database migration (if required)

@coderabbitai
Copy link

coderabbitai bot commented Feb 17, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Comment @coderabbitai help to get the list of available commands and usage tips.

@michaelhthomas michaelhthomas changed the title draft: API support for OIDC login feat: API support for OpenID Connect authentication Feb 17, 2026
const userinfo = options?.userinfoResponse ?? DEFAULT_CLAIMS;
const idTokenClaims = options?.idTokenClaims;

fetchMocker.mockIf(/example\.com/, async (req) => {

Check failure

Code scanning / CodeQL

Missing regular expression anchor High

When this is used as a regular expression on a URL, it may match anywhere, and arbitrary hosts may come before or after it.
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 High

This route handler performs
authorization
, but is not rate-limited.
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 High

This route handler performs
authorization
, but is not rate-limited.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant