mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-27 11:55:50 +00:00
Add support for log in with Apple (#2935)
* Add experimental login with Apple support * Fix revalidate URL * Fix OIDC provider revalidate in whoami as well
This commit is contained in:
committed by
GitHub
parent
b7f199ddbc
commit
872d2c01d4
@@ -260,6 +260,23 @@ To require email confirmation before login, also set `"strict_email_verification
|
||||
|
||||
Add `https://puter.<your-domain>/auth/oidc/callback/login` to the OAuth client's authorized redirect URIs in the Google Cloud Console. For non-Google providers, replace `google` with a custom id and supply `authorization_endpoint` / `token_endpoint` / `userinfo_endpoint` explicitly.
|
||||
|
||||
### Sign in with Apple
|
||||
|
||||
```json
|
||||
"oidc": {
|
||||
"providers": {
|
||||
"apple": {
|
||||
"client_id": "com.example.your-service-id",
|
||||
"team_id": "YOUR_TEAM_ID",
|
||||
"key_id": "YOUR_KEY_ID",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `client_id` is your Apple Services ID. `team_id`, `key_id`, and `private_key` come from the Apple Developer Portal (Keys section — create a key with "Sign in with Apple" enabled). The `private_key` is the contents of the `.p8` file Apple provides. Add `https://puter.<your-domain>/auth/oidc/callback/login` and `https://puter.<your-domain>/auth/oidc/callback/signup` as return URLs in the Apple Services ID configuration.
|
||||
|
||||
### AI providers
|
||||
|
||||
Any provider with a key set is auto-enabled. Same shape as `ollama` above:
|
||||
|
||||
@@ -100,13 +100,12 @@ extension.get(
|
||||
// OIDC revalidate URL for password-less accounts
|
||||
if (oidcOnly) {
|
||||
try {
|
||||
const providers = await services.oidc.getEnabledProviderIds();
|
||||
const provider = providers?.[0];
|
||||
const provider = await services.oidc.getLinkedProviderForUser(
|
||||
user.id as number,
|
||||
);
|
||||
if (provider) {
|
||||
const callbackUrl =
|
||||
services.oidc.getCallbackUrl?.('login') ?? '';
|
||||
const origin = callbackUrl.replace(
|
||||
/\/auth\/oidc\/callback\/login$/,
|
||||
const origin = (extension.config.origin ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
details.oidc_revalidate_url = `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_uuid=${encodeURIComponent(user.uuid)}`;
|
||||
|
||||
@@ -201,197 +201,190 @@ export class OIDCController extends PuterController {
|
||||
},
|
||||
);
|
||||
|
||||
// ── GET /auth/oidc/callback/login ───────────────────────────
|
||||
// ── /auth/oidc/callback/login (GET + POST) ────────────────
|
||||
|
||||
router.get(
|
||||
'/auth/oidc/callback/login',
|
||||
{
|
||||
subdomain: '',
|
||||
rateLimit: { scope: 'oidc-general', limit: 30, window: 60_000 },
|
||||
},
|
||||
async (req: Request, res: Response) => {
|
||||
const origin = this.config.origin ?? '';
|
||||
const result = await this.#processCallback(req, 'login');
|
||||
if ('error' in result) {
|
||||
console.warn(`OIDC login callback error: ${result.error}`);
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
result.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
const cbOpts = {
|
||||
subdomain: '',
|
||||
rateLimit: { scope: 'oidc-general', limit: 30, window: 60_000 },
|
||||
};
|
||||
|
||||
const { provider, userinfo, stateDecoded } = result;
|
||||
const resolved = await this.#resolveOrCreateOIDCUser(
|
||||
provider,
|
||||
userinfo,
|
||||
const loginCb = async (req: Request, res: Response) => {
|
||||
const origin = this.config.origin ?? '';
|
||||
const result = await this.#processCallback(req, 'login');
|
||||
if ('error' in result) {
|
||||
console.warn(`OIDC login callback error: ${result.error}`);
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
result.error,
|
||||
),
|
||||
);
|
||||
if ('error' in resolved) {
|
||||
console.warn(
|
||||
`OIDC login user resolution error: ${resolved.error}`,
|
||||
);
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
resolved.error,
|
||||
stateDecoded,
|
||||
),
|
||||
);
|
||||
}
|
||||
const user = resolved.user;
|
||||
}
|
||||
|
||||
if (user.suspended) {
|
||||
console.warn(
|
||||
`Suspended user tried to login via oidc: ${user.username}`,
|
||||
);
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
'This account is suspended.',
|
||||
stateDecoded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await this.#finishLogin(res, user, stateDecoded);
|
||||
},
|
||||
);
|
||||
|
||||
// ── GET /auth/oidc/callback/signup ──────────────────────────
|
||||
|
||||
router.get(
|
||||
'/auth/oidc/callback/signup',
|
||||
{
|
||||
subdomain: '',
|
||||
rateLimit: { scope: 'oidc-general', limit: 30, window: 60_000 },
|
||||
},
|
||||
async (req: Request, res: Response) => {
|
||||
const origin = this.config.origin ?? '';
|
||||
const result = await this.#processCallback(req, 'signup');
|
||||
if ('error' in result) {
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'signup',
|
||||
'other',
|
||||
'unauthorized',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const { provider, userinfo, stateDecoded } = result;
|
||||
const resolved = await this.#resolveOrCreateOIDCUser(
|
||||
provider,
|
||||
userinfo,
|
||||
const { provider, userinfo, stateDecoded } = result;
|
||||
const resolved = await this.#resolveOrCreateOIDCUser(
|
||||
provider,
|
||||
userinfo,
|
||||
);
|
||||
if ('error' in resolved) {
|
||||
console.warn(
|
||||
`OIDC login user resolution error: ${resolved.error}`,
|
||||
);
|
||||
if ('error' in resolved) {
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'signup',
|
||||
'other',
|
||||
'unauthorized',
|
||||
stateDecoded,
|
||||
),
|
||||
);
|
||||
}
|
||||
const user = resolved.user;
|
||||
|
||||
if (user.suspended) {
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'signup',
|
||||
'other',
|
||||
'account_suspended',
|
||||
stateDecoded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// If we landed on an existing account (either via the
|
||||
// provider_sub or via email match), signal the GUI so it can
|
||||
// render a "signed in" flow rather than "account created".
|
||||
const extra =
|
||||
resolved.origin === 'created'
|
||||
? undefined
|
||||
: { oidc_switched: 'login' };
|
||||
await this.#finishLogin(res, user, stateDecoded, extra);
|
||||
},
|
||||
);
|
||||
|
||||
// ── GET /auth/oidc/callback/revalidate ──────────────────────
|
||||
|
||||
router.get(
|
||||
'/auth/oidc/callback/revalidate',
|
||||
{
|
||||
subdomain: '',
|
||||
rateLimit: { scope: 'oidc-general', limit: 30, window: 60_000 },
|
||||
},
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
const result = await this.#processCallback(req, 'revalidate');
|
||||
if ('error' in result) {
|
||||
res.status(400).send(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const { provider, userinfo, stateDecoded } = result;
|
||||
if (
|
||||
stateDecoded.flow !== 'revalidate' ||
|
||||
typeof stateDecoded.user_uuid !== 'string' ||
|
||||
stateDecoded.user_uuid.length === 0
|
||||
) {
|
||||
res.status(400).send('Invalid revalidate state.');
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.services.oidc.findUserByProviderSub(
|
||||
provider,
|
||||
userinfo.sub,
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
resolved.error,
|
||||
stateDecoded,
|
||||
),
|
||||
);
|
||||
if (!user) {
|
||||
res.status(400).send('No account found.');
|
||||
return;
|
||||
}
|
||||
if (user.uuid !== stateDecoded.user_uuid) {
|
||||
res.status(403).send(
|
||||
'Wrong account. Sign in with the account linked to this session.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const user = resolved.user;
|
||||
|
||||
const token = this.services.oidc.signRevalidation(user.uuid);
|
||||
res.cookie(REVALIDATION_COOKIE_NAME, token, {
|
||||
// Revalidation flow is same-site only — `lax` even on HTTPS.
|
||||
...sessionCookieFlags(this.config, { crossSite: false }),
|
||||
httpOnly: true,
|
||||
maxAge: REVALIDATION_EXPIRY_SEC * 1000,
|
||||
path: '/',
|
||||
});
|
||||
if (user.suspended) {
|
||||
console.warn(
|
||||
`Suspended user tried to login via oidc: ${user.username}`,
|
||||
);
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
'This account is suspended.',
|
||||
stateDecoded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const origin = (this.config.origin ?? '').replace(/\/$/, '');
|
||||
const requested =
|
||||
(stateDecoded.redirect_uri as string) ||
|
||||
`${origin}/auth/revalidate-done`;
|
||||
const target = isSameOrigin(requested, origin)
|
||||
? requested
|
||||
: `${origin}/auth/revalidate-done`;
|
||||
res.redirect(302, target);
|
||||
},
|
||||
);
|
||||
await this.#finishLogin(res, user, stateDecoded);
|
||||
};
|
||||
router.get('/auth/oidc/callback/login', cbOpts, loginCb);
|
||||
router.post('/auth/oidc/callback/login', cbOpts, loginCb);
|
||||
|
||||
// ── /auth/oidc/callback/signup (GET + POST) ────────────────
|
||||
|
||||
const signupCb = async (req: Request, res: Response) => {
|
||||
const origin = this.config.origin ?? '';
|
||||
const result = await this.#processCallback(req, 'signup');
|
||||
if ('error' in result) {
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'signup',
|
||||
'other',
|
||||
'unauthorized',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const { provider, userinfo, stateDecoded } = result;
|
||||
const resolved = await this.#resolveOrCreateOIDCUser(
|
||||
provider,
|
||||
userinfo,
|
||||
);
|
||||
if ('error' in resolved) {
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'signup',
|
||||
'other',
|
||||
'unauthorized',
|
||||
stateDecoded,
|
||||
),
|
||||
);
|
||||
}
|
||||
const user = resolved.user;
|
||||
|
||||
if (user.suspended) {
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'signup',
|
||||
'other',
|
||||
'account_suspended',
|
||||
stateDecoded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// If we landed on an existing account (either via the
|
||||
// provider_sub or via email match), signal the GUI so it can
|
||||
// render a "signed in" flow rather than "account created".
|
||||
const extra =
|
||||
resolved.origin === 'created'
|
||||
? undefined
|
||||
: { oidc_switched: 'login' };
|
||||
await this.#finishLogin(res, user, stateDecoded, extra);
|
||||
};
|
||||
router.get('/auth/oidc/callback/signup', cbOpts, signupCb);
|
||||
router.post('/auth/oidc/callback/signup', cbOpts, signupCb);
|
||||
|
||||
// ── /auth/oidc/callback/revalidate (GET + POST) ────────────
|
||||
|
||||
const revalidateCb = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const result = await this.#processCallback(req, 'revalidate');
|
||||
if ('error' in result) {
|
||||
res.status(400).send(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const { provider, userinfo, stateDecoded } = result;
|
||||
if (
|
||||
stateDecoded.flow !== 'revalidate' ||
|
||||
typeof stateDecoded.user_uuid !== 'string' ||
|
||||
stateDecoded.user_uuid.length === 0
|
||||
) {
|
||||
res.status(400).send('Invalid revalidate state.');
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.services.oidc.findUserByProviderSub(
|
||||
provider,
|
||||
userinfo.sub,
|
||||
);
|
||||
if (!user) {
|
||||
res.status(400).send('No account found.');
|
||||
return;
|
||||
}
|
||||
if (user.uuid !== stateDecoded.user_uuid) {
|
||||
res.status(403).send(
|
||||
'Wrong account. Sign in with the account linked to this session.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this.services.oidc.signRevalidation(user.uuid);
|
||||
res.cookie(REVALIDATION_COOKIE_NAME, token, {
|
||||
// Revalidation flow is same-site only — `lax` even on HTTPS.
|
||||
...sessionCookieFlags(this.config, { crossSite: false }),
|
||||
httpOnly: true,
|
||||
maxAge: REVALIDATION_EXPIRY_SEC * 1000,
|
||||
path: '/',
|
||||
});
|
||||
|
||||
const origin = (this.config.origin ?? '').replace(/\/$/, '');
|
||||
const requested =
|
||||
(stateDecoded.redirect_uri as string) ||
|
||||
`${origin}/auth/revalidate-done`;
|
||||
const target = isSameOrigin(requested, origin)
|
||||
? requested
|
||||
: `${origin}/auth/revalidate-done`;
|
||||
res.redirect(302, target);
|
||||
};
|
||||
router.get('/auth/oidc/callback/revalidate', cbOpts, revalidateCb);
|
||||
router.post('/auth/oidc/callback/revalidate', cbOpts, revalidateCb);
|
||||
|
||||
// ── GET /auth/revalidate-done ───────────────────────────────
|
||||
// Landing page after revalidation; posts to opener for popup flow.
|
||||
@@ -500,15 +493,13 @@ if (window.opener) {
|
||||
stateDecoded: Record<string, unknown>;
|
||||
}
|
||||
> {
|
||||
// Apple uses response_mode=form_post, so params arrive in the body.
|
||||
const src = req.method === 'POST' && req.body ? req.body : req.query;
|
||||
const code = String(
|
||||
Array.isArray(req.query.code)
|
||||
? req.query.code[0]
|
||||
: (req.query.code ?? ''),
|
||||
Array.isArray(src.code) ? src.code[0] : (src.code ?? ''),
|
||||
);
|
||||
const state = String(
|
||||
Array.isArray(req.query.state)
|
||||
? req.query.state[0]
|
||||
: (req.query.state ?? ''),
|
||||
Array.isArray(src.state) ? src.state[0] : (src.state ?? ''),
|
||||
);
|
||||
if (!code || !state) return { error: 'Missing code or state.' };
|
||||
|
||||
@@ -531,6 +522,7 @@ if (window.opener) {
|
||||
const userinfo = await this.services.oidc.getUserInfo(
|
||||
provider,
|
||||
tokens.access_token,
|
||||
typeof tokens.id_token === 'string' ? tokens.id_token : undefined,
|
||||
);
|
||||
if (!userinfo || !userinfo.sub)
|
||||
return { error: 'Could not get user info.' };
|
||||
|
||||
@@ -82,9 +82,9 @@ async function buildRevalidateFields(
|
||||
user: UserRow,
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
const origin = (config.origin ?? '').replace(/\/$/, '');
|
||||
const providers = await oidcService.getEnabledProviderIds();
|
||||
const provider = providers && providers[0];
|
||||
if (!provider || !origin) return undefined;
|
||||
if (!origin) return undefined;
|
||||
const provider = await oidcService.getLinkedProviderForUser(user.id);
|
||||
if (!provider) return undefined;
|
||||
return {
|
||||
revalidate_url: `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_uuid=${encodeURIComponent(user.uuid)}`,
|
||||
};
|
||||
|
||||
@@ -25,10 +25,14 @@ import { cleanEmail, isBlockedEmail } from '../../util/email.js';
|
||||
import { generate_identifier } from '../../util/identifier.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { Context } from '../../core';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const GOOGLE_DISCOVERY_URL =
|
||||
'https://accounts.google.com/.well-known/openid-configuration';
|
||||
const APPLE_DISCOVERY_URL =
|
||||
'https://appleid.apple.com/.well-known/openid-configuration';
|
||||
const GOOGLE_SCOPES = 'openid email profile';
|
||||
const APPLE_SCOPES = 'openid email';
|
||||
const STATE_EXPIRY_SEC = 600; // 10 minutes
|
||||
const VALID_OIDC_FLOWS = ['login', 'signup', 'revalidate'] as const;
|
||||
const REVALIDATION_EXPIRY_SEC = 300; // 5 minutes
|
||||
@@ -40,6 +44,7 @@ interface ProviderConfig {
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint: string;
|
||||
scopes: string;
|
||||
response_mode?: string;
|
||||
}
|
||||
|
||||
interface OIDCUserInfo {
|
||||
@@ -62,7 +67,7 @@ interface OIDCUserInfo {
|
||||
export class OIDCService extends PuterService {
|
||||
declare protected services: LayerInstances<typeof puterServices>;
|
||||
|
||||
#googleDiscovery: Record<string, string> | null = null;
|
||||
#discoveryCache: Map<string, Record<string, string>> = new Map();
|
||||
#providers: Record<string, Record<string, string>> = {};
|
||||
|
||||
override onServerStart(): void {
|
||||
@@ -79,10 +84,39 @@ export class OIDCService extends PuterService {
|
||||
providerId: string,
|
||||
): Promise<ProviderConfig | null> {
|
||||
const raw = this.#providers[providerId];
|
||||
if (!raw || !raw.client_id || !raw.client_secret) return null;
|
||||
if (!raw?.client_id) return null;
|
||||
|
||||
if (providerId === 'apple') {
|
||||
if (!raw.team_id || !raw.key_id || !raw.private_key) return null;
|
||||
const discovery = await this.#fetchDiscovery(
|
||||
APPLE_DISCOVERY_URL,
|
||||
'Apple',
|
||||
);
|
||||
if (!discovery) return null;
|
||||
return {
|
||||
client_id: raw.client_id,
|
||||
client_secret: this.#generateAppleClientSecret(
|
||||
raw.team_id,
|
||||
raw.client_id,
|
||||
raw.key_id,
|
||||
raw.private_key,
|
||||
),
|
||||
authorization_endpoint: discovery.authorization_endpoint,
|
||||
token_endpoint: discovery.token_endpoint,
|
||||
userinfo_endpoint: '',
|
||||
scopes: raw.scopes ?? APPLE_SCOPES,
|
||||
response_mode: 'form_post',
|
||||
};
|
||||
}
|
||||
|
||||
// Google and custom providers require a static client_secret.
|
||||
if (!raw.client_secret) return null;
|
||||
|
||||
if (providerId === 'google') {
|
||||
const discovery = await this.#fetchGoogleDiscovery();
|
||||
const discovery = await this.#fetchDiscovery(
|
||||
GOOGLE_DISCOVERY_URL,
|
||||
'Google',
|
||||
);
|
||||
if (!discovery) return null;
|
||||
return {
|
||||
client_id: raw.client_id,
|
||||
@@ -148,6 +182,9 @@ export class OIDCService extends PuterService {
|
||||
scope: config.scopes,
|
||||
state,
|
||||
});
|
||||
if (config.response_mode) {
|
||||
params.set('response_mode', config.response_mode);
|
||||
}
|
||||
return `${config.authorization_endpoint}?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -223,15 +260,24 @@ export class OIDCService extends PuterService {
|
||||
async getUserInfo(
|
||||
providerId: string,
|
||||
accessToken: string,
|
||||
idToken?: string,
|
||||
): Promise<OIDCUserInfo | null> {
|
||||
const config = await this.getProviderConfig(providerId);
|
||||
if (!config?.userinfo_endpoint) return null;
|
||||
|
||||
const res = await fetch(config.userinfo_endpoint, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as OIDCUserInfo;
|
||||
if (config?.userinfo_endpoint) {
|
||||
const res = await fetch(config.userinfo_endpoint, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as OIDCUserInfo;
|
||||
}
|
||||
|
||||
// No userinfo endpoint — decode claims from the id_token (e.g. Apple).
|
||||
if (idToken) {
|
||||
return this.#decodeIdToken(idToken);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── User lookup / creation ──────────────────────────────────────
|
||||
@@ -248,6 +294,12 @@ export class OIDCService extends PuterService {
|
||||
return this.stores.user.getById(link.user_id as number);
|
||||
}
|
||||
|
||||
async getLinkedProviderForUser(userId: number): Promise<string | null> {
|
||||
const links = await this.stores.oidc.listByUserId(userId);
|
||||
if (!links || links.length === 0) return null;
|
||||
return links[0].provider as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing Puter user by the email claimed by the OIDC provider.
|
||||
*
|
||||
@@ -511,18 +563,70 @@ export class OIDCService extends PuterService {
|
||||
|
||||
// ── Internals ───────────────────────────────────────────────────
|
||||
|
||||
async #fetchGoogleDiscovery(): Promise<Record<string, string> | null> {
|
||||
if (this.#googleDiscovery) return this.#googleDiscovery;
|
||||
async #fetchDiscovery(
|
||||
url: string,
|
||||
label: string,
|
||||
): Promise<Record<string, string> | null> {
|
||||
const cached = this.#discoveryCache.get(url);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const res = await fetch(GOOGLE_DISCOVERY_URL);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
this.#googleDiscovery = (await res.json()) as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
return this.#googleDiscovery;
|
||||
const data = (await res.json()) as Record<string, string>;
|
||||
this.#discoveryCache.set(url, data);
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.warn('[oidc] Google discovery fetch failed', e);
|
||||
console.warn(`[oidc] ${label} discovery fetch failed`, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#generateAppleClientSecret(
|
||||
teamId: string,
|
||||
clientId: string,
|
||||
keyId: string,
|
||||
privateKey: string,
|
||||
): string {
|
||||
const header = { alg: 'ES256', kid: keyId, typ: 'JWT' };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
iss: teamId,
|
||||
sub: clientId,
|
||||
aud: 'https://appleid.apple.com',
|
||||
iat: now,
|
||||
exp: now + 15777000, // ~6 months
|
||||
};
|
||||
|
||||
const headerB64 = Buffer.from(JSON.stringify(header)).toString(
|
||||
'base64url',
|
||||
);
|
||||
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString(
|
||||
'base64url',
|
||||
);
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
|
||||
const key = crypto.createPrivateKey(privateKey);
|
||||
const signature = crypto.sign('sha256', Buffer.from(signingInput), {
|
||||
key,
|
||||
dsaEncoding: 'ieee-p1363',
|
||||
});
|
||||
|
||||
return `${signingInput}.${signature.toString('base64url')}`;
|
||||
}
|
||||
|
||||
#decodeIdToken(idToken: string): OIDCUserInfo | null {
|
||||
try {
|
||||
const parts = idToken.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(parts[1], 'base64url').toString(),
|
||||
);
|
||||
return {
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
email_verified: payload.email_verified,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,12 @@ export interface IOIDCProviderConfig {
|
||||
userinfo_endpoint?: string;
|
||||
/** Space-separated OAuth scopes. Default depends on provider. */
|
||||
scopes?: string;
|
||||
/** Apple Developer Team ID (apple provider only). */
|
||||
team_id?: string;
|
||||
/** Key ID for the Sign in with Apple private key (apple provider only). */
|
||||
key_id?: string;
|
||||
/** PKCS#8 PEM private key content from Apple (apple provider only). */
|
||||
private_key?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,8 @@ async function UIWindowLogin (options) {
|
||||
// OIDC
|
||||
h += '<div class="oidc-providers-wrapper" style="display:none; padding: 0 0 10px 0;">';
|
||||
h += `<div style="text-align:center; margin: 10px 0; font-size:13px; color:var(--color-text-muted);">${ i18n('or') }</div>`;
|
||||
h += `<button type="button" class="oidc-google-btn button button-block button-normal" style="display:flex; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA" />${i18n('sign_in_with_google')}</button>`;
|
||||
h += `<button type="button" class="oidc-google-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,UklGRu4GAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSAUDAAABoATJtmnbGs+23+vZtm3btm3btnHxjG/btm3bnI197tljzjXjtSNiAnAjmbNs8x5DR40e2qtFhTzuVJ2e+rrE/ODKwsZe5J90n9CfXVw6vLEvifIH87OHVOK0mLxSLpSyd4vZhyuGkP+amL45j7lVYn6+rQpfSYDvFzO0QgIdYeZJCTbNRpnfJeDXLTSQsH/X6yiBj1TrIoEPgnZzCXwwtMtL4AOhnfP/wPpC/X0Jux/Uj4jySzsGVskJADkq9dn4JKEP1NuJ6gMDELvrrTF6QP9fjX0FQc2xLZme0E8Tfko20LOmZdAD+tWF/lVlqNb7I9IFBt+hpUD9CZGuMNhT2ONh8GJvWPyK1QZedhdyS7j5Bqkf3Kwo3A3w8yTneTgq3NyOdOZMhqPplM/h6Z+Urp6UF+ZX8HQGZZIrVylw9SvGnRqP33ev2QdSEwlzpIaYTlCOUtSLQpGujN/hRePIFMZDbgyOrGUcdGNe5CBjoRtbIimMMW4ciVxgDHQjxb0LkRTGGF8OMha6kRZZyzjkxrHIFMbDbmyPdGX84cbiSDmGFPNiZASU0V60SvAV4y4vyiS4yhCNSuXZxb5lIOEMyngFRSF+nqg85YsAOjNuSoQ/GdLJXhpjfgbplE/tCbN2Bp0pMtHaBAoy5khOY78yHkjiJOdZW3OEOSmJihxZaymPUPMlgTc40sfQy5RnkWx3kjQ3c0ioI5LClyRpZWSRcJF8T5aMNbFKuHti4B2WnDVwUcj541SnyVeVlCp/KeQ0xE6jiZzNppD1jNDzxcO/PJG9BUhF9gl/B4jtNEQeGBAv55gnRfFfUI+oiMhLOwZWyRnJXKbltLOfim4nDt5XMn0N5Jz/e/Eb6OW9KMtDcx8GQrOLB6ug2zG8fdBuENoB6Jf5PaiVMPlkQP1hdEUov5aH2QpfBXETTK+y91dnGM9/zdh2BFj2bkNn8iHMEqeN7MiJgMe+pPZwP4Sef9J9vL8vD8oMH6tOT309zo+P7BlcAs7mLNu8x9BRY4b1aVO9MG4cAQBWUDggwgMAABAVAJ0BKmAAYAA+bSyTRqQiIaEtVEyQgA2JbAC++hZa3jl94/Jj2rrA/Tvv9y7x+OzH+L5o/8p7APMA/TPpD+YD+bf4T9bPfW/mfqo/wHqAf0v/hdYB6AH7Aemp+5HwS/uN+6XtXdQB1N/Rj+m/RWYp6GESYsQNCm+TZ3BvtEgBK+isF38GQzHNMrw+tkbAm5lUM4rWqU9srOS+RuAUa8nFYoFrGH73Fzd++s+LPyEGAAD+Yg+gQRuJCapqteWj86DdkHmfHdS+mWVn7Ue8RiK5AaWCIe7RXaDKn0baNe80expR5tTBclgnd+SEzNZ6N3NL1suPhGwRqKzB5wNnWUXD7R0IkT5uIwUhTsO/1ucVEMKvTZRe4j7WwrA+e53P8J1/cdIvKtrtZv9AH5u1heZ1dGQELuxMxbQUnrw6IUh0YLBKEjNnR3JIsFNwkC+WLw4wkCMati73lcMjT6U2KIYsr0g5bPzSmo3ir9qRjtIqthpPIvuvYt8AbMewSj96NIqTM14M6ABtOaY87d8HOU7baFGGQjU1f8vf7SV5AjDrfW0VVzQcBez+PYiPApyQUAozEmZFKBDvX9ifFFacoulG9NCPDXa1mRPVwzrKuN3VfwUfv8Bx8QJ88prsOCDd8UvNDnUzbRqBXOmQ4co+/xYy/iQV89GHs4pb0D2LYwv6aY8yPLCrYyAPUe4dKEh/FBqRBJ/716CJVMTCrvB+gSgPwNjN5LlnNQqkBsHnSqdUFddjvBKNdm6zm8ggLFJl14S37sJW1fAEbiB0IqxcqlJOWol+ecJy9xdESchmlROmo/4/9V34VN2cw8NUB5POPUNzivyMFfgF27QTFXgya/LF3OsfHMF6Itejgy4ab38VPFjez5f8Z/b/dhYV21SflE4KtUj8LZDrQXRp+Pzdf/gWfvm2oLh0x+Fr+ggo2jjG3DFn95Wj1P3ra1RtzYE8ffXBeR2lDhL+GPJiiZaVinGMkjVYup0AghAhIfVgFtsL8lhMyJY84ccksHz92o1/J3NfrGSM8NwOv7ieeBjq2eqCOmrUHpG3mlr8Hj/fR4d+orLlpipLTh3+cRY7suXQXw9SPYZieoKS/JE9y0f+BcWCzdNshWl2oVpeK2qm36iFltfGZUvXdfBKufE9RkZcyOBDUVeONlBchjuaLPNd6fSlk41Pp57nF5MmES2Kl7HqY+f2pvS71eIgUXpqtAFIY94JSmNre+g49tL8VnZlJNqgVhj/De//5zH/84h//5uJfunlawY99GgDkm8cYFbIAAAA" />${i18n('sign_in_with_google')}</button>`;
|
||||
h += `<button type="button" class="oidc-apple-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_in_with_apple')}</button>`;
|
||||
h += '</div>';
|
||||
h += '</form>';
|
||||
h += '</div>';
|
||||
@@ -170,10 +171,12 @@ async function UIWindowLogin (options) {
|
||||
const res = await fetch(`${window.api_origin}/auth/oidc/providers`);
|
||||
if ( ! res.ok ) return;
|
||||
const data = await res.json();
|
||||
if ( data.providers && data.providers.includes('google') ) {
|
||||
$(el_window).find('.oidc-providers-wrapper').show();
|
||||
$(el_window).find('.oidc-google-btn').on('click', function () {
|
||||
let url = `${window.gui_origin}/auth/oidc/google/start?flow=login`;
|
||||
if ( ! data.providers ) return;
|
||||
|
||||
const wireOidcBtn = (provider, btnClass) => {
|
||||
$(el_window).find(btnClass).css('display', 'flex');
|
||||
$(el_window).find(btnClass).on('click', function () {
|
||||
let url = `${window.gui_origin}/auth/oidc/${provider}/start?flow=login`;
|
||||
if ( window.embedded_in_popup && window.url_query_params?.get('msg_id') ) {
|
||||
url += `&embedded_in_popup=true&msg_id=${encodeURIComponent(window.url_query_params.get('msg_id'))}`;
|
||||
if ( window.openerOrigin ) {
|
||||
@@ -182,7 +185,12 @@ async function UIWindowLogin (options) {
|
||||
}
|
||||
window.location.href = url;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let hasProvider = false;
|
||||
if ( data.providers.includes('google') ) { hasProvider = true; wireOidcBtn('google', '.oidc-google-btn'); }
|
||||
if ( data.providers.includes('apple') ) { hasProvider = true; wireOidcBtn('apple', '.oidc-apple-btn'); }
|
||||
if ( hasProvider ) $(el_window).find('.oidc-providers-wrapper').show();
|
||||
} catch (_) {
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -98,7 +98,8 @@ function UIWindowSignup (options) {
|
||||
h += '</form>';
|
||||
h += '<div class="oidc-providers-wrapper" style="display:none; padding: 10px 0;">';
|
||||
h += `<div style="text-align:center; margin: 10px 0; font-size:13px;">${ i18n('or') }</div>`;
|
||||
h += `<button type="button" class="oidc-google-btn button button-block button-normal" style="display:flex; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,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" />${i18n('sign_up_with_google')}</button>`;
|
||||
h += `<button type="button" class="oidc-google-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px;"><img style="width: 20px; height: 20px;" src="data:image/webp;base64,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" />${i18n('sign_up_with_google')}</button>`;
|
||||
h += `<button type="button" class="oidc-apple-btn button button-block button-normal" style="display:none; align-items:center; justify-content:center; gap:8px; margin-top:8px;"><svg style="width:20px; height:20px;" viewBox="0 0 384 512" fill="currentColor"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>${i18n('sign_up_with_apple')}</button>`;
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
// login link
|
||||
@@ -171,10 +172,12 @@ function UIWindowSignup (options) {
|
||||
const res = await fetch(`${window.api_origin}/auth/oidc/providers`);
|
||||
if ( ! res.ok ) return;
|
||||
const data = await res.json();
|
||||
if ( data.providers && data.providers.includes('google') ) {
|
||||
$(el_window).find('.oidc-providers-wrapper').show();
|
||||
$(el_window).find('.oidc-google-btn').on('click', function () {
|
||||
let url = `${window.gui_origin}/auth/oidc/google/start?flow=signup`;
|
||||
if ( ! data.providers ) return;
|
||||
|
||||
const wireOidcBtn = (provider, btnClass) => {
|
||||
$(el_window).find(btnClass).css('display', 'flex');
|
||||
$(el_window).find(btnClass).on('click', function () {
|
||||
let url = `${window.gui_origin}/auth/oidc/${provider}/start?flow=signup`;
|
||||
if ( window.embedded_in_popup && window.url_query_params?.get('msg_id') ) {
|
||||
url += `&embedded_in_popup=true&msg_id=${encodeURIComponent(window.url_query_params.get('msg_id'))}`;
|
||||
if ( window.openerOrigin ) {
|
||||
@@ -183,7 +186,12 @@ function UIWindowSignup (options) {
|
||||
}
|
||||
window.location.href = url;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let hasProvider = false;
|
||||
if ( data.providers.includes('google') ) { hasProvider = true; wireOidcBtn('google', '.oidc-google-btn'); }
|
||||
if ( data.providers.includes('apple') ) { hasProvider = true; wireOidcBtn('apple', '.oidc-apple-btn'); }
|
||||
if ( hasProvider ) $(el_window).find('.oidc-providers-wrapper').show();
|
||||
} catch (_) {
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -513,6 +513,8 @@ const en = {
|
||||
'signup_confirm_password': 'Confirm Password',
|
||||
sign_in_with_google: 'Sign in with Google',
|
||||
sign_up_with_google: 'Sign up with Google',
|
||||
sign_in_with_apple: 'Sign in with Apple',
|
||||
sign_up_with_apple: 'Sign up with Apple',
|
||||
oidc_switched_to_login_message: 'You have been logged in to an existing account.',
|
||||
|
||||
// Login Window
|
||||
|
||||
Reference in New Issue
Block a user