Merge 7a9b6ae919
into b6fe5ac637
commit
fc87988538
@ -0,0 +1,143 @@
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import type { Request } from 'express';
|
||||
import * as yup from 'yup';
|
||||
|
||||
/** Fetch the oidc configuration blob */
|
||||
export async function getOIDCWellknownConfiguration(domain: string) {
|
||||
// remove trailing slash from url if it exists and add /.well-known/openid-configuration path
|
||||
const wellKnownUrl = new URL(
|
||||
`https://${domain}`.replace(/\/$/, '') + '/.well-known/openid-configuration'
|
||||
).toString();
|
||||
const wellKnownInfo: WellKnownConfiguration = await fetch(wellKnownUrl, {
|
||||
headers: new Headers([['Content-Type', 'application/json']]),
|
||||
}).then((r) => r.json());
|
||||
|
||||
return wellKnownInfo;
|
||||
}
|
||||
|
||||
export async function getOIDCRedirectUrl(req: Request, state: string) {
|
||||
const settings = getSettings();
|
||||
const { oidcDomain, oidcClientId } = settings.main;
|
||||
|
||||
const wellKnownInfo = await getOIDCWellknownConfiguration(oidcDomain);
|
||||
const url = new URL(wellKnownInfo.authorization_endpoint);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', oidcClientId);
|
||||
|
||||
// Use X-Forwarded-Proto if available, otherwise fall back to req.protocol
|
||||
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
|
||||
const callbackUrl = new URL('/api/v1/auth/oidc-callback', `${protocol}://${req.headers.host}`).toString();
|
||||
url.searchParams.set('redirect_uri', callbackUrl);
|
||||
url.searchParams.set('scope', 'openid profile email');
|
||||
url.searchParams.set('state', state);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
|
||||
export const createJwtSchema = ({
|
||||
oidcDomain,
|
||||
oidcClientId,
|
||||
}: {
|
||||
oidcDomain: string;
|
||||
oidcClientId: string;
|
||||
}) => {
|
||||
return yup.object().shape({
|
||||
iss: yup
|
||||
.string()
|
||||
.oneOf(
|
||||
[`https://${oidcDomain}`, `https://${oidcDomain}/`],
|
||||
`The token iss value doesn't match the oidc_DOMAIN (${oidcDomain})`
|
||||
)
|
||||
.required("The token didn't come with an iss value."),
|
||||
|
||||
aud: yup
|
||||
.mixed()
|
||||
.test(
|
||||
'aud-test',
|
||||
`The token aud value doesn't match the oidc_CLIENT_ID (${oidcClientId})`,
|
||||
(value) => {
|
||||
const audience = Array.isArray(value) ? value : [value];
|
||||
return audience.includes(oidcClientId);
|
||||
}
|
||||
)
|
||||
.required("The token didn't come with an aud value."),
|
||||
|
||||
exp: yup
|
||||
.number()
|
||||
.required()
|
||||
.test(
|
||||
'is_before_date',
|
||||
'Token exp value is before current time.',
|
||||
(value) => {
|
||||
// Check if 'value' is undefined
|
||||
if (value === undefined) return false;
|
||||
return value >= Math.ceil(Date.now() / 1000);
|
||||
}
|
||||
),
|
||||
|
||||
iat: yup
|
||||
.number()
|
||||
.required()
|
||||
.test(
|
||||
'is_before_one_day',
|
||||
'Token was issued before one day ago and is now invalid.',
|
||||
(value) => {
|
||||
// Check if 'value' is undefined
|
||||
if (value === undefined) return false;
|
||||
const oneDayAgo = Math.ceil(Number(new Date()) / 1000) - 86400;
|
||||
return value >= oneDayAgo;
|
||||
}
|
||||
),
|
||||
|
||||
email: yup.string().email().required(),
|
||||
email_verified: yup.boolean().required(),
|
||||
});
|
||||
};
|
||||
|
||||
export interface WellKnownConfiguration {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
device_authorization_endpoint: string;
|
||||
userinfo_endpoint: string;
|
||||
mfa_challenge_endpoint: string;
|
||||
jwks_uri: string;
|
||||
registration_endpoint: string;
|
||||
revocation_endpoint: string;
|
||||
scopes_supported: string[];
|
||||
response_types_supported: string[];
|
||||
code_challenge_methods_supported: string[];
|
||||
response_modes_supported: string[];
|
||||
subject_types_supported: string[];
|
||||
id_token_signing_alg_values_supported: string[];
|
||||
token_endpoint_auth_methods_supported: string[];
|
||||
claims_supported: string[];
|
||||
request_uri_parameter_supported: boolean;
|
||||
}
|
||||
|
||||
export interface OIDCJwtPayload {
|
||||
// Standard OIDC Claims
|
||||
iss: string; // Issuer Identifier
|
||||
sub: string; // Subject Identifier
|
||||
aud: string | string[]; // Audience
|
||||
exp: number; // Expiration time
|
||||
iat: number; // Issued at time
|
||||
auth_time?: number; // Time when the authentication occurred (optional)
|
||||
nonce?: string; // String value used to associate a Client session with an ID Token (optional)
|
||||
|
||||
// Commonly used OIDC Claims
|
||||
email?: string; // User's email address (optional)
|
||||
email_verified?: boolean; // Whether the user's email address has been verified (optional)
|
||||
name?: string; // User's full name (optional)
|
||||
given_name?: string; // User's given name(s) or first name(s) (optional)
|
||||
family_name?: string; // User's surname(s) or last name(s) (optional)
|
||||
preferred_username?: string; // Shorthand name by which the user wishes to be referred to (optional)
|
||||
locale?: string; // User's locale (optional)
|
||||
zoneinfo?: string; // User's time zone (optional)
|
||||
|
||||
// Other possible custom claims (these depend on your OIDC provider)
|
||||
// Include any additional fields that your OIDC provider might use
|
||||
[additionalClaim: string]: unknown;
|
||||
}
|
||||
|
@ -0,0 +1,53 @@
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import OIDCAuth from '@app/utils/oidc';
|
||||
import { ArrowLeftOnRectangleIcon} from '@heroicons/react/24/outline';
|
||||
import { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
signinwithoidc: 'Sign In With {provider}',
|
||||
});
|
||||
|
||||
type Props = {
|
||||
revalidate: () => void;
|
||||
oidcName: string;
|
||||
};
|
||||
|
||||
const oidcAuth = new OIDCAuth();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function OIDCLoginButton({ revalidate, oidcName }: Props) {
|
||||
const intl = useIntl();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const handleClick = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await oidcAuth.preparePopup();
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
return;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="block w-full rounded-md shadow-sm">
|
||||
<button
|
||||
className="plex-button bg-indigo-500 hover:bg-indigo-600"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon />
|
||||
<span>
|
||||
{loading
|
||||
? intl.formatMessage(globalMessages.loading)
|
||||
: intl.formatMessage(messages.signinwithoidc, {
|
||||
provider: oidcName,
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default OIDCLoginButton;
|
@ -0,0 +1,45 @@
|
||||
import PopupWindow from './popupWindow';
|
||||
|
||||
class OIDCAuth extends PopupWindow {
|
||||
public async preparePopup(): Promise<void> {
|
||||
this.openPopup({
|
||||
title: 'OIDC Auth',
|
||||
path: '/api/v1/auth/oidc-login',
|
||||
w: 600,
|
||||
h: 700,
|
||||
});
|
||||
|
||||
return this.pollLoginState();
|
||||
}
|
||||
|
||||
private async pollLoginState(): Promise<void> {
|
||||
const executePoll = async (
|
||||
resolve: () => void,
|
||||
reject: (e: Error) => void
|
||||
) => {
|
||||
try {
|
||||
if (!this.popup) {
|
||||
throw new Error('Unable to poll when popup is not initialized.');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/auth/me');
|
||||
|
||||
if (response.ok) {
|
||||
this.closePopup();
|
||||
resolve();
|
||||
} else if (!response.ok && !this.popup?.closed) {
|
||||
setTimeout(executePoll, 1000, resolve, reject);
|
||||
} else {
|
||||
reject(new Error('Popup closed without completing login'));
|
||||
}
|
||||
} catch (e) {
|
||||
this.closePopup();
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
return new Promise(executePoll);
|
||||
}
|
||||
}
|
||||
|
||||
export default OIDCAuth;
|
@ -0,0 +1,66 @@
|
||||
abstract class PopupWindow {
|
||||
protected popup?: Window;
|
||||
|
||||
public abstract preparePopup(): Promise<void>;
|
||||
|
||||
protected closePopup(): void {
|
||||
this.popup?.close();
|
||||
this.popup = undefined;
|
||||
}
|
||||
|
||||
protected openPopup({
|
||||
title,
|
||||
path,
|
||||
w,
|
||||
h,
|
||||
}: {
|
||||
title: string;
|
||||
path?: string;
|
||||
w: number;
|
||||
h: number;
|
||||
}): Window | void {
|
||||
if (!window) {
|
||||
throw new Error(
|
||||
'Window is undefined. Are you running this in the browser?'
|
||||
);
|
||||
}
|
||||
// Fixes dual-screen position Most browsers Firefox
|
||||
const dualScreenLeft =
|
||||
window.screenLeft != undefined ? window.screenLeft : window.screenX;
|
||||
const dualScreenTop =
|
||||
window.screenTop != undefined ? window.screenTop : window.screenY;
|
||||
const width = window.innerWidth
|
||||
? window.innerWidth
|
||||
: document.documentElement.clientWidth
|
||||
? document.documentElement.clientWidth
|
||||
: screen.width;
|
||||
const height = window.innerHeight
|
||||
? window.innerHeight
|
||||
: document.documentElement.clientHeight
|
||||
? document.documentElement.clientHeight
|
||||
: screen.height;
|
||||
const left = width / 2 - w / 2 + dualScreenLeft;
|
||||
const top = height / 2 - h / 2 + dualScreenTop;
|
||||
|
||||
//Set url to login/popup/loading so browser doesn't block popup
|
||||
const newWindow = window.open(
|
||||
path || '/login/popup/loading',
|
||||
title,
|
||||
'scrollbars=yes, width=' +
|
||||
w +
|
||||
', height=' +
|
||||
h +
|
||||
', top=' +
|
||||
top +
|
||||
', left=' +
|
||||
left
|
||||
);
|
||||
if (newWindow) {
|
||||
newWindow.focus();
|
||||
this.popup = newWindow;
|
||||
return this.popup;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PopupWindow;
|
Loading…
Reference in new issue