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:
ProgrammerIn-wonderland
2026-05-06 16:47:24 -04:00
committed by GitHub
parent b7f199ddbc
commit 872d2c01d4
9 changed files with 364 additions and 228 deletions
+17
View File
@@ -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:
+5 -6
View File
@@ -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)}`;
+181 -189
View File
@@ -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)}`,
};
+122 -18
View File
@@ -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;
}
}
+6
View File
@@ -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;
}
+14 -6
View File
@@ -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,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" />${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,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" />${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 (_) {
}
})();
+14 -6
View File
@@ -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 (_) {
}
})();
+2
View File
@@ -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