diff --git a/doc/self-hosting.md b/doc/self-hosting.md index 61b0accbc..16dde5309 100644 --- a/doc/self-hosting.md +++ b/doc/self-hosting.md @@ -260,6 +260,23 @@ To require email confirmation before login, also set `"strict_email_verification Add `https://puter./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./auth/oidc/callback/login` and `https://puter./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: diff --git a/extensions/whoami.ts b/extensions/whoami.ts index ab493017e..bdbb22af0 100644 --- a/extensions/whoami.ts +++ b/extensions/whoami.ts @@ -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)}`; diff --git a/src/backend/controllers/oidc/OIDCController.ts b/src/backend/controllers/oidc/OIDCController.ts index 94cb30602..7a62c1568 100644 --- a/src/backend/controllers/oidc/OIDCController.ts +++ b/src/backend/controllers/oidc/OIDCController.ts @@ -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 => { - 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 => { + 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; } > { + // 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.' }; diff --git a/src/backend/core/http/middleware/userProtected.ts b/src/backend/core/http/middleware/userProtected.ts index 6339f9448..e631476a1 100644 --- a/src/backend/core/http/middleware/userProtected.ts +++ b/src/backend/core/http/middleware/userProtected.ts @@ -82,9 +82,9 @@ async function buildRevalidateFields( user: UserRow, ): Promise | 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)}`, }; diff --git a/src/backend/services/auth/OIDCService.ts b/src/backend/services/auth/OIDCService.ts index 2eeebcf68..068caa894 100644 --- a/src/backend/services/auth/OIDCService.ts +++ b/src/backend/services/auth/OIDCService.ts @@ -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; - #googleDiscovery: Record | null = null; + #discoveryCache: Map> = new Map(); #providers: Record> = {}; override onServerStart(): void { @@ -79,10 +84,39 @@ export class OIDCService extends PuterService { providerId: string, ): Promise { 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 { 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 { + 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 | null> { - if (this.#googleDiscovery) return this.#googleDiscovery; + async #fetchDiscovery( + url: string, + label: string, + ): Promise | 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; + 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; } } diff --git a/src/backend/types.ts b/src/backend/types.ts index 99120a859..f9f2c28ff 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -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; } diff --git a/src/gui/src/UI/UIWindowLogin.js b/src/gui/src/UI/UIWindowLogin.js index 573627489..eca9f6035 100644 --- a/src/gui/src/UI/UIWindowLogin.js +++ b/src/gui/src/UI/UIWindowLogin.js @@ -91,7 +91,8 @@ async function UIWindowLogin (options) { // OIDC h += ''; h += ''; h += ''; @@ -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 (_) { } })(); diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 1fef2b9b0..6f6f8b6dd 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -98,7 +98,8 @@ function UIWindowSignup (options) { h += ''; h += ''; h += ''; // 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 (_) { } })(); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index e81d0d1f6..88bdbeb69 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -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