diff --git a/CHANGELOG.md b/CHANGELOG.md index 043f0d9..a23921a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (#58) - Additional unit coverage for registration UIA helpers and signup pages (`matrixRegistrationUia.spec.ts`, page specs) +- Matrix delegated OIDC sign-up (MSC2965 / MAS): OpenID discovery, PKCE, + dynamic client registration, token exchange, `/auth/matrix-oidc/callback`, + and Matrix JS SDK session wiring (`matrixOidcNative.ts`, `useMatrixClient` + with persisted OIDC client and token-endpoint metadata for refresh) +- Sign-up entry point for delegated browser OAuth; runtime + `nuxt.public.siteUrl` / `matrixOidcClientId`; EN/DE i18n for callback and + error paths; README notes on HTTPS redirect requirements for matrix.org ### Changed diff --git a/README.md b/README.md index 0cbdf6b..8674282 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,22 @@ Signup uses Matrix **User-Interactive Authentication** against `/register`: first request may return **401** with `session`, `flows`, and `params`; the client keeps the session and completes stages in order. -**Supported stages today** +**Delegated auth (MAS / OAuth — e.g. matrix.org)** + +If `/.well-known/matrix/client` includes `org.matrix.msc2965.authentication`, +the homeserver typically **blocks legacy `POST /register`** for web clients but +delegates new-account creation (and related flows) to a Matrix Authentication + Service (OAuth/OIDC). + +- Set **`NUXT_PUBLIC_SITE_URL`** to your app's public **`https://` origin** + **without path** (Matrix dynamic client registration rejects `http://localhost` + redirects; use an HTTPS tunnel / preview URL for local OAuth). +- Optional **`NUXT_PUBLIC_MATRIX_OIDC_CLIENT_ID`** — static OAuth client id if you + register one yourself. +- Redirect/callback **`/auth/matrix-oidc/callback`** completes the PKCE exchange, + restores the Matrix JS SDK session (`matrixOidcNative.ts`, `useMatrixClient`). + +**Supported stages today (legacy `/register` UIA)** | Stage | Notes | | --- | --- | @@ -147,9 +162,9 @@ and `params`; the client keeps the session and completes stages in order. **Explicit non-support** -- **`m.login.sso`** – SSO/OIDC signup is not implemented in-app. Users see a - message to complete registration via a Matrix web client (e.g. Element), - then sign in here. +- **`m.login.sso`** as an in-flow stage **after `/register` already returned UIA** + (`session` + flows) — not wired in-app; where MSC2965 is advertised, prefer + the **delegated OAuth** button on the **sign-up** page instead. - **`m.login.msisdn`** – Phone/SMS registration is not implemented; users get a clear “not supported” message instead of failing silently. diff --git a/app/components/auth/SignupRecaptchaStep.vue b/app/components/auth/SignupRecaptchaStep.vue index 4fd06ad..0a99b0d 100644 --- a/app/components/auth/SignupRecaptchaStep.vue +++ b/app/components/auth/SignupRecaptchaStep.vue @@ -26,11 +26,28 @@ const { const v3Running = ref(false) const v3Error = ref('') -const widgetAllowed = computed(() => canLoadGoogleRecaptcha()) +const needsExplicitGoogleTransferAck = computed(() => !iubendaConfigured()) +const acknowledgesGoogleTransfer = ref(false) + +const widgetAllowed = computed(() => { + if (!canLoadGoogleRecaptcha()) { + return false + } + if (needsExplicitGoogleTransferAck.value && !acknowledgesGoogleTransfer.value) { + return false + } + return true +}) const policyHref = computed(() => privacyPolicyUrl()) function onConsentContinue(): void { + if ( + needsExplicitGoogleTransferAck.value && + !acknowledgesGoogleTransfer.value + ) { + return + } grantLocalRecaptchaConsent() } @@ -95,10 +112,24 @@ function onRecaptchaV2Verified(response: unknown): void { + +
{{ translateText('auth.signUpAgreeLoadRecaptcha') }} diff --git a/app/composables/matrix/matrixClientShared.ts b/app/composables/matrix/matrixClientShared.ts index 63419fa..8837b2d 100644 --- a/app/composables/matrix/matrixClientShared.ts +++ b/app/composables/matrix/matrixClientShared.ts @@ -129,6 +129,33 @@ export function readMatrixErrorCode(error: unknown): string { ) } +/** + * Narrow {@link HOMESERVER_CONNECTION_HINT_ERROR} mapping to cases where fetch + * failed before a usable Matrix JSON body (no {@link readMatrixErrorCode}, + * no HTTP status). Signup PR #60 added broad browser-message matching; without + * this, legitimate API failures can be mislabeled as “homeserver unreachable”. + */ +export function isTransportFailureWithoutMatrixBody( + error: unknown +): boolean { + if (!isLikelyBrowserNetworkOrCorsError(error)) { + return false + } + if (readMatrixErrorCode(error)) { + return false + } + if (!error || typeof error !== 'object') { + return true + } + const shaped = error as MatrixApiErrorShape + const httpStatus = shaped.httpStatus ?? shaped.statusCode + return !( + typeof httpStatus === 'number' && + Number.isFinite(httpStatus) && + httpStatus > 0 + ) +} + export function readMatrixErrorMessage(error: unknown): string { if (error instanceof Error && error.message.trim()) { return error.message @@ -144,6 +171,23 @@ export function readMatrixErrorMessage(error: unknown): string { ) } +/** + * Homeserver refuses open registration via POST /register (Synapse/matrix.org). + * Separate from flows Decentra can complete via UIA when a session exists. + */ +export function isPublicRegisterEndpointDisabled(error: unknown): boolean { + const raw = readMatrixErrorMessage(error) + const normalized = raw.toLowerCase() + return ( + normalized.includes('registration has been disabled') || + normalized.includes('registration is disabled') || + ( + normalized.includes('application_service') && + normalized.includes('registrations are allowed') + ) + ) +} + export function isSignupUnsupported(error: unknown): boolean { const matrixErrorCode = readMatrixErrorCode(error) if ( diff --git a/app/composables/matrix/matrixOidcNative.ts b/app/composables/matrix/matrixOidcNative.ts new file mode 100644 index 0000000..1163fd1 --- /dev/null +++ b/app/composables/matrix/matrixOidcNative.ts @@ -0,0 +1,553 @@ +/** + * Native Matrix delegated auth (MAS / MSC2965 OAuth2 + PKCE) for homeservers like + * matrix.org where POST /register is closed but OIDC is advertised in + * .well-known/matrix/client. + */ + +import { resolveHomeserverBaseUrlForClient } from './matrixClientShared' + +export const MATRIX_DELEGATED_OIDC_CALLBACK_RELATIVE_PATH = + '/auth/matrix-oidc/callback' + +export const MATRIX_OIDC_STATE_STORAGE_PREFIX = 'decentra.matrix.oidc.state:' +export const MATRIX_OIDC_DYNAMIC_CLIENT_STORAGE_KEY = + 'decentra.matrix.oidc.dynamicClient.v1' + +export const MATRIX_OIDC_HTTPS_ORIGIN_REQUIRED_ERROR = + 'MATRIX_OIDC_HTTPS_ORIGIN_REQUIRED' +export const MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR = + 'MATRIX_OIDC_NO_DELEGATED_AUTH' +export const MATRIX_OIDC_REGISTRATION_REJECTED_ERROR = + 'MATRIX_OIDC_REGISTRATION_REJECTED' +export const MATRIX_OIDC_INVALID_CALLBACK_ERROR = + 'MATRIX_OIDC_INVALID_CALLBACK' + +const MSC2965_KEY = 'org.matrix.msc2965.authentication' + +const MSC2967_API_SCOPE = + 'urn:matrix:org.matrix.msc2967.client:api:*' + +export type MatrixOidcIntent = 'login' | 'signup' + +/** Homeserver /.well-known/matrix/client subset we need. */ +export type MatrixDelegatedClientHints = { + matrixClientApiBaseUrl: string + oidcIssuerNormalized: string +} + +export type StoredDynamicOidcClients = Record< + string, + { client_id: string; origin: string } +> + +export type PendingOidcState = { + v: 1 + codeVerifier: string + nonce: string + redirectUri: string + matrixClientApiBaseUrl: string + oidcIssuer: string + tokenEndpoint: string + registrationEndpoint: string + oidcDeviceId: string + oauthClientId: string +} + +export type NativeOidcTokens = { + accessToken: string + refreshToken: string | null + expiresInSeconds: number | null +} + +function normalizeIssuerUrl(raw: string): string { + const trimmed = raw.trim() + if (!trimmed) { + return '' + } + const withScheme = trimmed.includes('://') ? trimmed : `https://${trimmed}` + try { + const url = new URL(withScheme) + return url.origin + '/' + } catch { + return trimmed.endsWith('/') ? trimmed : `${trimmed}/` + } +} + +function randomUrlSafeString(byteLength: number): string { + const bytes = new Uint8Array(byteLength) + crypto.getRandomValues(bytes) + let text = '' + for (let index = 0; index < bytes.length; index += 1) { + text += String.fromCharCode(bytes[index] ?? 0) + } + return btoa(text) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +async function sha256Base64Url(plain: string): Promise { + const data = new TextEncoder().encode(plain) + const digest = await crypto.subtle.digest('SHA-256', data) + const bytes = new Uint8Array(digest) + let binary = '' + for (let index = 0; index < bytes.byteLength; index += 1) { + binary += String.fromCharCode(bytes[index] ?? 0) + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') +} + +function normalizeMatrixDeviceId(candidate: string): string { + const cleaned = candidate.replace(/[^a-zA-Z0-9-]/g, '') + if (cleaned.length >= 10) { + return cleaned.slice(0, 32) + } + const padded = `${cleaned}DECENTRAOIDC` + .replace(/[^a-zA-Z0-9-]/g, '') + return padded.slice(0, 22) +} + +export function generateMatrixOidcDeviceId(): string { + const raw = randomUrlSafeString(16).replace(/[^a-zA-Z0-9-]/g, '') + return normalizeMatrixDeviceId(raw) +} + +export async function fetchMatrixDelegatedClientHints( + homeserverUserInputOrigin: string +): Promise { + const trimmed = homeserverUserInputOrigin.trim() + if (!trimmed) { + throw new Error(MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR) + } + const delegateOrigin = resolveHomeserverBaseUrlForClient(trimmed) + if (!delegateOrigin) { + throw new Error(MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR) + } + const resolved = `${delegateOrigin}/.well-known/matrix/client` + let response: Response + try { + response = await fetch(resolved) + } catch { + throw new Error(MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR) + } + if (!response.ok) { + throw new Error(MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR) + } + let body: unknown + try { + body = await response.json() + } catch { + throw new Error(MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR) + } + if (!body || typeof body !== 'object') { + throw new Error(MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR) + } + const record = body as Record + const homeserverBlock = record['m.homeserver'] + if (!homeserverBlock || typeof homeserverBlock !== 'object') { + throw new Error(MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR) + } + const baseUrlRaw = (homeserverBlock as { base_url?: unknown }).base_url + if (typeof baseUrlRaw !== 'string' || !baseUrlRaw.trim()) { + throw new Error(MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR) + } + let matrixClientApiBaseUrl: string + try { + matrixClientApiBaseUrl = new URL(baseUrlRaw.trim()).origin + } catch { + throw new Error(MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR) + } + const authBlock = record[MSC2965_KEY] + if (!authBlock || typeof authBlock !== 'object') { + throw new Error(MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR) + } + const issuerRaw = (authBlock as { issuer?: unknown }).issuer + if (typeof issuerRaw !== 'string' || !issuerRaw.trim()) { + throw new Error(MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR) + } + return { + matrixClientApiBaseUrl, + oidcIssuerNormalized: normalizeIssuerUrl(issuerRaw.trim()) + } +} + +export type OpenIdIssuerMetadata = { + issuer: string + authorization_endpoint: string + token_endpoint: string + registration_endpoint?: string +} + +export async function fetchOpenIdIssuerMetadata( + issuerUrl: string +): Promise { + const normalized = normalizeIssuerUrl(issuerUrl) + const docUrl = `${normalized}.well-known/openid-configuration` + const response = await fetch(docUrl) + if (!response.ok) { + throw new Error(MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR) + } + const record = (await response.json()) as Record + const authorization = record.authorization_endpoint + const token = record.token_endpoint + if (typeof authorization !== 'string' || typeof token !== 'string') { + throw new Error(MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR) + } + const registration = record.registration_endpoint + return { + issuer: typeof record.issuer === 'string' ? record.issuer : normalized, + authorization_endpoint: authorization, + token_endpoint: token, + registration_endpoint: typeof registration === 'string' + ? registration + : undefined + } +} + +function readDynamicClientMap(): StoredDynamicOidcClients { + if (typeof window === 'undefined') { + return {} + } + const raw = localStorage.getItem(MATRIX_OIDC_DYNAMIC_CLIENT_STORAGE_KEY) + if (!raw) { + return {} + } + try { + const parsed = JSON.parse(raw) as StoredDynamicOidcClients + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +function writeDynamicClientEntry( + issuerNormalized: string, + client_id: string, + origin: string +): void { + if (typeof window === 'undefined') { + return + } + const next = readDynamicClientMap() + next[issuerNormalized] = { client_id, origin } + localStorage.setItem( + MATRIX_OIDC_DYNAMIC_CLIENT_STORAGE_KEY, + JSON.stringify(next) + ) +} + +export async function ensurePublicOidcDynamicClientRegistered(options: { + registrationEndpoint: string + issuerNormalized: string + appOriginHttps: string + callbackPath: string +}): Promise<{ client_id: string }> { + const redirectUri = + `${options.appOriginHttps.replace(/\/+$/, '')}${options.callbackPath}` + const clientUriRoot = `${options.appOriginHttps.replace(/\/+$/, '')}/` + + const body = { + client_name: 'Decentra', + client_uri: clientUriRoot, + redirect_uris: [redirectUri], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none' + } + + const response = await fetch(options.registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + const text = await response.text() + if (!response.ok) { + const message = + `OIDC dynamic client registration failed (${response.status}): ` + + `${text}` + throw new Error(message) + } + try { + const json = JSON.parse(text) as { client_id?: string } + const client_id = typeof json.client_id === 'string' + ? json.client_id.trim() + : '' + if (!client_id) { + throw new Error(MATRIX_OIDC_REGISTRATION_REJECTED_ERROR) + } + writeDynamicClientEntry(options.issuerNormalized, client_id, options.appOriginHttps) + return { client_id } + } catch (thrown) { + if (thrown instanceof Error && thrown.message) { + throw thrown + } + throw new Error(MATRIX_OIDC_REGISTRATION_REJECTED_ERROR) + } +} + +/** Public HTTPS deployment origin (no trailing path). For tunnel or prod URL. */ +export function resolveTrustedAppHttpsOrigin(siteUrlConfigured: string): string { + const trimmed = siteUrlConfigured.trim() + if (!trimmed) { + return '' + } + const probe = trimmed.includes('://') ? trimmed : `https://${trimmed}` + let url: URL + try { + url = new URL(probe) + } catch { + return '' + } + if (url.protocol !== 'https:') { + return '' + } + return url.origin.replace(/\/+$/, '') +} + +export async function resolveOidcPublicClientId(options: { + matrixOidcClientIdConfigured: string + registrationEndpoint: string + issuerNormalized: string + trustedAppOrigin: string + callbackPath: string +}): Promise { + const configured = options.matrixOidcClientIdConfigured.trim() + if (configured) { + return configured + } + const existing = readDynamicClientMap()[options.issuerNormalized] + if ( + existing && + existing.origin === options.trustedAppOrigin && + existing.client_id + ) { + return existing.client_id + } + const registered = await ensurePublicOidcDynamicClientRegistered({ + registrationEndpoint: options.registrationEndpoint, + issuerNormalized: options.issuerNormalized, + appOriginHttps: options.trustedAppOrigin, + callbackPath: options.callbackPath + }) + return registered.client_id +} + +/** Browser redirect starts OAuth (authorization_code + PKCE). */ +export async function redirectToMatrixNativeOidc(options: { + homeserverUrlInput: string + trustedAppHttpsOrigin: string + runtimeClientIdConfigured: string + callbackPath: string + intent: MatrixOidcIntent +}): Promise { + if (typeof window === 'undefined') { + return + } + const delegated = await fetchMatrixDelegatedClientHints( + options.homeserverUrlInput + ) + const issuerMeta = await fetchOpenIdIssuerMetadata( + delegated.oidcIssuerNormalized + ) + if (!issuerMeta.registration_endpoint) { + throw new Error(MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR) + } + const oauthClientId = await resolveOidcPublicClientId({ + matrixOidcClientIdConfigured: options.runtimeClientIdConfigured, + registrationEndpoint: issuerMeta.registration_endpoint, + issuerNormalized: normalizeIssuerUrl(issuerMeta.issuer), + trustedAppOrigin: options.trustedAppHttpsOrigin, + callbackPath: options.callbackPath + }) + + const codeVerifier = randomUrlSafeString(48) + const codeChallenge = await sha256Base64Url(codeVerifier) + const state = randomUrlSafeString(24) + const nonce = randomUrlSafeString(24) + const oidcDeviceId = generateMatrixOidcDeviceId() + const scopeList = [ + 'openid', + 'email', + MSC2967_API_SCOPE, + `urn:matrix:org.matrix.msc2967.client:device:${oidcDeviceId}` + ] + const redirectUri = + `${options.trustedAppHttpsOrigin.replace(/\/+$/, '')}` + + `${options.callbackPath}` + + const pending: PendingOidcState = { + v: 1, + codeVerifier, + nonce, + redirectUri, + matrixClientApiBaseUrl: delegated.matrixClientApiBaseUrl, + oidcIssuer: issuerMeta.issuer, + tokenEndpoint: issuerMeta.token_endpoint, + registrationEndpoint: issuerMeta.registration_endpoint, + oidcDeviceId, + oauthClientId + } + sessionStorage.setItem( + `${MATRIX_OIDC_STATE_STORAGE_PREFIX}${state}`, + JSON.stringify(pending) + ) + + const params = new URLSearchParams({ + response_type: 'code', + client_id: oauthClientId, + redirect_uri: redirectUri, + scope: scopeList.join(' '), + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: 'S256' + }) + if (options.intent === 'signup') { + params.set('prompt', 'create') + } else { + params.set('prompt', 'login') + } + const target = `${issuerMeta.authorization_endpoint}?${params.toString()}` + window.location.assign(target) +} + +export async function exchangeNativeOidcAuthorizationCode(options: { + code: string + state: string +}): Promise<{ + tokens: NativeOidcTokens + pending: PendingOidcState +}> { + if (typeof window === 'undefined') { + throw new Error(MATRIX_OIDC_INVALID_CALLBACK_ERROR) + } + const stateKey = `${MATRIX_OIDC_STATE_STORAGE_PREFIX}${options.state}` + const raw = sessionStorage.getItem(stateKey) + sessionStorage.removeItem(stateKey) + if (!raw) { + throw new Error(MATRIX_OIDC_INVALID_CALLBACK_ERROR) + } + let pending: PendingOidcState + try { + pending = JSON.parse(raw) as PendingOidcState + } catch { + throw new Error(MATRIX_OIDC_INVALID_CALLBACK_ERROR) + } + if (!pending || pending.v !== 1 || !pending.codeVerifier) { + throw new Error(MATRIX_OIDC_INVALID_CALLBACK_ERROR) + } + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: options.code.trim(), + redirect_uri: pending.redirectUri, + client_id: pending.oauthClientId, + code_verifier: pending.codeVerifier + }) + const response = await fetch(pending.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString() + }) + const text = await response.text() + if (!response.ok) { + throw new Error( + `OIDC token exchange failed (${response.status}): ${text}` + ) + } + const json = JSON.parse(text) as { + access_token?: string + refresh_token?: string + expires_in?: number + } + const accessToken = typeof json.access_token === 'string' + ? json.access_token + : '' + if (!accessToken) { + throw new Error(MATRIX_OIDC_INVALID_CALLBACK_ERROR) + } + const refreshToken = typeof json.refresh_token === 'string' + ? json.refresh_token + : null + const expiresInSeconds = typeof json.expires_in === 'number' + ? json.expires_in + : null + return { + tokens: { + accessToken, + refreshToken, + expiresInSeconds + }, + pending + } +} + +export async function fetchMatrixWhoAmI( + matrixClientApiBaseUrl: string, + accessToken: string +): Promise<{ userId: string }> { + const url = + `${matrixClientApiBaseUrl.replace(/\/+$/, '')}` + + `/_matrix/client/v3/account/whoami` + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` } + }) + const text = await response.text() + if (!response.ok) { + throw new Error(`whoami failed (${response.status}): ${text}`) + } + const json = JSON.parse(text) as { user_id?: string } + const userId = typeof json.user_id === 'string' ? json.user_id.trim() : '' + if (!userId) { + throw new Error(MATRIX_OIDC_INVALID_CALLBACK_ERROR) + } + return { userId } +} + +export async function refreshNativeOidcAccessToken(options: { + refreshToken: string + tokenEndpoint: string + clientId: string +}): Promise { + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: options.refreshToken.trim(), + client_id: options.clientId.trim() + }) + const response = await fetch(options.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString() + }) + const text = await response.text() + if (!response.ok) { + throw new Error( + `OIDC refresh failed (${response.status}): ${text}` + ) + } + const json = JSON.parse(text) as { + access_token?: string + refresh_token?: string + expires_in?: number + } + const accessToken = typeof json.access_token === 'string' + ? json.access_token + : '' + if (!accessToken) { + throw new Error(MATRIX_OIDC_INVALID_CALLBACK_ERROR) + } + const refreshToken = typeof json.refresh_token === 'string' + ? json.refresh_token + : null + const expiresInSeconds = typeof json.expires_in === 'number' + ? json.expires_in + : null + return { + accessToken, + refreshToken, + expiresInSeconds + } +} diff --git a/app/composables/matrix/matrixRegistrationUia.ts b/app/composables/matrix/matrixRegistrationUia.ts index 6bc9b7d..8bedc2d 100644 --- a/app/composables/matrix/matrixRegistrationUia.ts +++ b/app/composables/matrix/matrixRegistrationUia.ts @@ -4,7 +4,8 @@ import * as sdk from 'matrix-js-sdk' import { HOMESERVER_CONNECTION_HINT_ERROR, extractUserLocalpart, - isLikelyBrowserNetworkOrCorsError, + isTransportFailureWithoutMatrixBody, + isPublicRegisterEndpointDisabled, isSignupUnsupported, readMatrixErrorCode, readMatrixErrorMessage, @@ -13,6 +14,8 @@ import { } from '~/composables/matrix/matrixClientShared' export const SIGNUP_UNAVAILABLE_ERROR = 'SIGNUP_UNAVAILABLE' +/** POST /register closed for normal clients (e.g. matrix.org API policy). */ +export const SIGNUP_REGISTER_API_CLOSED_ERROR = 'SIGNUP_REGISTER_API_CLOSED' export const SIGNUP_EMAIL_VERIFICATION_REQUIRED_ERROR = 'SIGNUP_EMAIL_VERIFICATION_REQUIRED' export const SIGNUP_EMAIL_NOT_CONFIRMED_YET = 'SIGNUP_EMAIL_NOT_CONFIRMED_YET' @@ -120,6 +123,13 @@ function readMatrixUiaData(error: unknown): MatrixUiaData | null { } } +/** Synapse/matrix.org-style “register endpoint closed” detection. */ +function throwIfPublicRegisterDisabled(error: unknown): void { + if (isPublicRegisterEndpointDisabled(error)) { + throw new Error(SIGNUP_REGISTER_API_CLOSED_ERROR) + } +} + /** Registration token MSC stage aliases (homeserver-provided literal). */ export function isRegistrationTokenStage(stage: string): boolean { if (!stage.trim()) { @@ -803,11 +813,12 @@ export async function startEmailRegistration( clearSignupPending() return } catch (error) { - if (isLikelyBrowserNetworkOrCorsError(error)) { + if (isTransportFailureWithoutMatrixBody(error)) { throw new Error(HOMESERVER_CONNECTION_HINT_ERROR) } const uia = readMatrixUiaData(error) - if (!uia?.session) { + if (!uia || !uia.session) { + throwIfPublicRegisterDisabled(error) if (isSignupUnsupported(error)) { throw new Error(SIGNUP_UNAVAILABLE_ERROR) } @@ -833,8 +844,10 @@ export async function startEmailRegistration( } throw error } - const picked = pickCompletableEmailSignupFlow(uia.flows) + const signupUiaSnapshot = uia + const picked = pickCompletableEmailSignupFlow(signupUiaSnapshot.flows) if (!picked.ok) { + throwIfPublicRegisterDisabled(error) if (isSignupUnsupported(error)) { throw new Error(SIGNUP_UNAVAILABLE_ERROR) } @@ -850,14 +863,14 @@ export async function startEmailRegistration( throw new Error(SIGNUP_EMAIL_VERIFICATION_REQUIRED_ERROR) } const flowStages = picked.stages - const paramsRaw = uia.params + const paramsRaw = signupUiaSnapshot.params const params: Record = paramsRaw && typeof paramsRaw === 'object' && !Array.isArray(paramsRaw) ? (paramsRaw as Record) : {} - const completedInitial = uia.completed || [] + const completedInitial = signupUiaSnapshot.completed || [] const firstStageRaw = getNextAuthStage(flowStages, completedInitial) const recMeta = extractRecaptchaFromParams(params) @@ -871,8 +884,8 @@ export async function startEmailRegistration( email: trimmed, clientSecret, sid: '', - session: uia.session ?? '', - initialSession: uia.session ?? '', + session: signupUiaSnapshot.session ?? '', + initialSession: signupUiaSnapshot.session ?? '', flowStages, paramsSnapshot: Object.keys(params).length > 0 ? params : undefined, recaptchaSiteKey: recMeta?.siteKey, @@ -1031,7 +1044,7 @@ async function runSignupFinalizeLoop( clearSignupPending() return } catch (error) { - if (isLikelyBrowserNetworkOrCorsError(error)) { + if (isTransportFailureWithoutMatrixBody(error)) { throw new Error(HOMESERVER_CONNECTION_HINT_ERROR) } if ( @@ -1116,6 +1129,7 @@ async function runSignupFinalizeLoop( ) { throw new Error(SIGNUP_EMAIL_NOT_CONFIRMED_YET) } + throwIfPublicRegisterDisabled(error) if (isSignupUnsupported(error)) { throw new Error(SIGNUP_UNAVAILABLE_ERROR) } @@ -1162,7 +1176,7 @@ export async function submitSignupRecaptcha( clearSignupPending() return } catch (error) { - if (isLikelyBrowserNetworkOrCorsError(error)) { + if (isTransportFailureWithoutMatrixBody(error)) { throw new Error(HOMESERVER_CONNECTION_HINT_ERROR) } if (isLikelyRecaptchaRejected(error)) { @@ -1170,6 +1184,7 @@ export async function submitSignupRecaptcha( } const uia = readMatrixUiaData(error) if (!uia) { + throwIfPublicRegisterDisabled(error) if (isSignupUnsupported(error)) { throw new Error(SIGNUP_UNAVAILABLE_ERROR) } @@ -1246,7 +1261,7 @@ export async function submitSignupRegistrationToken( clearSignupPending() return } catch (error) { - if (isLikelyBrowserNetworkOrCorsError(error)) { + if (isTransportFailureWithoutMatrixBody(error)) { throw new Error(HOMESERVER_CONNECTION_HINT_ERROR) } if (isLikelyRegistrationTokenRejected(error)) { @@ -1254,6 +1269,7 @@ export async function submitSignupRegistrationToken( } const uia = readMatrixUiaData(error) if (!uia) { + throwIfPublicRegisterDisabled(error) if (isSignupUnsupported(error)) { throw new Error(SIGNUP_UNAVAILABLE_ERROR) } @@ -1321,7 +1337,7 @@ export async function submitSignupTermsAcceptance(): Promise { clearSignupPending() return } catch (error) { - if (isLikelyBrowserNetworkOrCorsError(error)) { + if (isTransportFailureWithoutMatrixBody(error)) { throw new Error(HOMESERVER_CONNECTION_HINT_ERROR) } if (isLikelyTermsRejected(error)) { @@ -1329,6 +1345,7 @@ export async function submitSignupTermsAcceptance(): Promise { } const uia = readMatrixUiaData(error) if (!uia) { + throwIfPublicRegisterDisabled(error) if (isSignupUnsupported(error)) { throw new Error(SIGNUP_UNAVAILABLE_ERROR) } @@ -1416,9 +1433,10 @@ export async function registerWithDummy( } throw new Error(SIGNUP_UNAVAILABLE_ERROR) } catch (error) { - if (isLikelyBrowserNetworkOrCorsError(error)) { + if (isTransportFailureWithoutMatrixBody(error)) { throw new Error(HOMESERVER_CONNECTION_HINT_ERROR) } + throwIfPublicRegisterDisabled(error) if (isSignupUnsupported(error)) { throw new Error(SIGNUP_UNAVAILABLE_ERROR) } diff --git a/app/composables/recaptchaGoogle.ts b/app/composables/recaptchaGoogle.ts index 76918a0..da1e832 100644 --- a/app/composables/recaptchaGoogle.ts +++ b/app/composables/recaptchaGoogle.ts @@ -2,16 +2,28 @@ * Loads Google reCAPTCHA v3 client script and executes once for a site key. */ -declare global { - interface Window { - grecaptcha?: { - ready: (callback: () => void) => void - execute: ( - siteKey: string, - options: { action: string } - ) => Promise - } +type RecaptchaV3Api = { + ready: (callback: () => void) => void + execute: ( + siteKey: string, + options: { action: string } + ) => Promise +} + +function readRecaptchaV3FromWindow(): RecaptchaV3Api | undefined { + const bridge = window as unknown as { + grecaptcha?: Record + } + const candidate = bridge.grecaptcha + const executeCandidate = candidate?.execute + const readyCandidate = candidate?.ready + if ( + typeof executeCandidate !== 'function' || + typeof readyCandidate !== 'function' + ) { + return undefined } + return candidate as unknown as RecaptchaV3Api } function scriptSelector(siteKey: string): string { @@ -23,7 +35,7 @@ async function ensureRecaptchaV3Script(siteKey: string): Promise { throw new Error('recaptcha-v3-no-document') } const existing = document.querySelector(scriptSelector(siteKey)) - if (existing && window.grecaptcha?.execute) { + if (existing && readRecaptchaV3FromWindow()?.execute) { return } await new Promise((resolvePromise, rejectPromise) => { @@ -44,7 +56,7 @@ export async function executeGoogleRecaptchaV3( action: string ): Promise { await ensureRecaptchaV3Script(siteKey) - const client = window.grecaptcha + const client = readRecaptchaV3FromWindow() if (!client?.execute || !client.ready) { throw new Error('recaptcha-v3-unavailable') } diff --git a/app/composables/useAppI18n.ts b/app/composables/useAppI18n.ts index e8e4c5a..ce9ef06 100644 --- a/app/composables/useAppI18n.ts +++ b/app/composables/useAppI18n.ts @@ -14,6 +14,14 @@ const messages: Record> = { 'auth.signInFailed': 'Sign in failed', 'auth.signUpFailed': 'Sign up failed', 'auth.signUpUnavailable': 'Sign-up is not available on this homeserver', + 'auth.signUpRegisterApiClosed': + 'Sign-up through this Decentra page is not available on this ' + + 'homeserver: registration is restricted to portals or approved ' + + 'web entry points—the generic Matrix registration API Decentra ' + + 'uses is blocked or disabled here.\n\n' + + 'If the operator offers account creation on the web, try their ' + + 'site starting at:\n{homeserverPortal}\n\n' + + 'After you have an account, sign in below.', 'auth.signUpEmailVerificationRequired': 'Sign-up requires email verification on this homeserver', 'auth.signUpSuccess': 'Account created. Please sign in.', 'auth.signUpEmailSentTitle': 'Check your email', @@ -36,9 +44,9 @@ const messages: Record> = { 'This homeserver needs an additional sign-up step that Decentra ' + 'does not support yet.', 'auth.signUpSsoUseWebClient': - 'This homeserver only allows SSO sign-up. Please use the official ' + - 'Element web client in a browser, then continue in Decentra after ' + - 'your account exists.', + 'This homeserver only accepts SSO for new accounts. Complete ' + + 'sign-up via your homeserver operator\'s SSO or web registration ' + + 'page, then sign in here once the account exists.', 'auth.signUpMsisdnUnsupported': 'This homeserver expects phone-number (SMS) confirmation. Decentra ' + 'does not support SMS sign-up yet.', @@ -76,10 +84,34 @@ const messages: Record> = { 'reCAPTCHA verification failed. Please try again.', 'auth.signUpRecaptchaRequired': 'Complete reCAPTCHA to continue.', + 'auth.signUpRecaptchaTransferAck': + 'I understand that loading reCAPTCHA sends data ' + + '(including IP-related telemetry if applicable) ' + + 'to Google.', + 'auth.signUpClassicRegistrationDivider': + 'Classic sign-up via homeserver registration API', + 'auth.matrixOidcSignupIntro': + 'On this homeserver new accounts use the delegated Matrix login ' + + 'service (MAS / OAuth), not the classic /register form in this dialog.', + 'auth.matrixOidcSignupButton': 'Continue Matrix sign-up in browser…', + 'auth.matrixOidcSignupFinePrint': + 'After you confirm in the Matrix window, Decentra continues here.', + 'auth.matrixOidcNeedsHttpsSiteUrl': + 'Set NUXT_PUBLIC_SITE_URL to your public HTTPS app origin so OAuth ' + + 'redirects work (e.g. HTTPS preview URL—not plain http localhost).', + 'auth.matrixOidcCallbackTitle': 'Completing Matrix sign-in', + 'auth.matrixOidcCallbackBusy': 'Finishing delegated login…', + 'auth.matrixOidcMissingCodeState': + 'OAuth callback was missing authorization code or state.', + 'auth.matrixOidcCallbackInvalid': + 'Delegated login expired or tampered—start OAuth again.', + 'auth.matrixOidcCallbackFailedRaw': '{detail}', + 'auth.matrixOidcBackToLogin': 'Return to login', 'auth.homeserverConnectionHint': - 'Cannot reach the homeserver from the browser. For public ' + - 'servers use https:// (not http://) so a redirect does not break ' + - 'CORS preflight. Synapse must allow this app origin in CORS.', + 'Unable to reach the Matrix client API from this page. Use ' + + 'https:// for public servers (not plain http://). Check your ' + + 'network. Some operators also limit which browser origins may ' + + 'call the login API—even when the homeserver URL is correct.', 'auth.restoringSession': 'Restoring session...', 'chat.loggedInAs': 'Signed in as', 'chat.signOut': 'Sign out', @@ -145,6 +177,17 @@ const messages: Record> = { 'settings.verificationMismatch': 'Verification cancelled because emojis did not match.', 'settings.verificationCancelled': 'Verification was cancelled.', 'settings.verificationCrossSigningHint': 'Cross-signing might need setup on your other client first.', + 'settings.verificationNeedCrossSigning': + 'Cross-signing is not available on this account yet; set it up ' + + 'in another Matrix client before verifying this device.', + 'settings.verificationReadyTimeout': + 'Timed out waiting for the other client. Open your other session, ' + + 'accept verification, then try again.', + 'settings.verificationUnknownOtherDevice': + 'Could not load the other device yet. Confirm the other client is ' + + 'online and tap Refresh status, then try again.', + 'settings.verificationProtocolError': + 'Verification could not continue — try cancelling and starting again.', 'settings.backToChat': 'Back to chat', 'settings.spaceTitle': 'Space settings', 'settings.spaceDescription': 'Basic settings for this space.', @@ -227,6 +270,14 @@ const messages: Record> = { 'auth.signInFailed': 'Anmeldung fehlgeschlagen', 'auth.signUpFailed': 'Registrierung fehlgeschlagen', 'auth.signUpUnavailable': 'Registrierung ist auf diesem Homeserver nicht verfuegbar', + 'auth.signUpRegisterApiClosed': + 'Ueber diese Decentra-Seite kannst du dich auf diesem Homeserver ' + + 'nicht registrieren: Dort ist die Kontenerstellung auf bestimmte ' + + 'portale oder zugelassene Web-Angebote beschraenkt oder die fuer ' + + 'Decentra noetige generische Matrix-Registrierung ist abgeschaltet.\n\n' + + 'Wenn der Betreiber Registrierung im Web anbietet, probiere dort ' + + 'beginnend mit:\n{homeserverPortal}\n\n' + + 'Wenn der Account steht, melde dich unten an.', 'auth.signUpEmailVerificationRequired': 'Registrierung erfordert E-Mail-Verifizierung auf diesem Homeserver', 'auth.signUpSuccess': 'Account erstellt. Bitte melde dich an.', 'auth.signUpEmailSentTitle': 'E-Mail pruefen', @@ -253,9 +304,9 @@ const messages: Record> = { 'Dieser Homeserver verlangt einen weiteren Registrierungs-Schritt, ' + 'den Decentra noch nicht unterstuetzt.', 'auth.signUpSsoUseWebClient': - 'Dieser Homeserver erlaubt die Registrierung nur ueber SSO. Bitte ' + - 'die offizielle Element-Web-App im Browser nutzen, danach kannst du ' + - 'hier fortfahren, sobald ein Account besteht.', + 'Dieser Homeserver akzeptiert Neukonten nur ueber SSO. Bitte ' + + 'Registrierung ueber das SSO bzw. Web-Angebot des Betreibers ' + + 'abschliessen, danach hier anmelden.', 'auth.signUpMsisdnUnsupported': 'Dieser Homeserver erwartet Bestaetigung per Mobilnummer/SMS. ' + 'Decentra unterstuetzt diese SMS-Registrierung noch nicht.', @@ -297,10 +348,38 @@ const messages: Record> = { 'reCAPTCHA-Verifikation fehlgeschlagen. Bitte erneut versuchen.', 'auth.signUpRecaptchaRequired': 'Bitte reCAPTCHA abschliessen, um fortzufahren.', + 'auth.signUpRecaptchaTransferAck': + 'Mir ist klar: Beim Laden von reCAPTCHA werden Daten unter ' + + 'Umstaenden an Google uebermittelt.', + 'auth.signUpClassicRegistrationDivider': + 'Klassische Registrierung ueber die Legacy-Registration-API', + 'auth.matrixOidcSignupIntro': + 'Auf diesem Homeserver laufen neue Konten ueber den delegierten ' + + 'Matrix-Anmeldeservice (MAS / OAuth), nicht ueber das klassische ' + + '/register-Formular in diesem Dialog.', + 'auth.matrixOidcSignupButton': + 'Mit Matrix-Webfenster registrieren…', + 'auth.matrixOidcSignupFinePrint': + 'Nach dem Abschluss dort kehrst du hier automatisch weiter.', + 'auth.matrixOidcNeedsHttpsSiteUrl': + 'Fuer OAuth-Bruecken setze NUXT_PUBLIC_SITE_URL auf eine oeffentliche ' + + 'HTTPS-Origin (z. B. Tunnel-Preview, nicht nur http localhost).', + 'auth.matrixOidcCallbackTitle': + 'Matrix-Anmeldung wird abgeschlossen', + 'auth.matrixOidcCallbackBusy': + 'Mit dem Anmeldeservice unterhalten…', + 'auth.matrixOidcMissingCodeState': + 'OAuth-Rueckruf ohne code oder state.', + 'auth.matrixOidcCallbackInvalid': + 'Anmeldedaten ungueltig oder abgelaufen—OAuth erneut starten.', + 'auth.matrixOidcCallbackFailedRaw': '{detail}', + 'auth.matrixOidcBackToLogin': 'Zurueck zum Login', 'auth.homeserverConnectionHint': - 'Homeserver aus dem Browser nicht erreichbar. Oeffentliche ' + - 'Server: https:// statt http:// (sonst bricht CORS-Preflight). Synapse ' + - 'muss diese App-Origin in CORS erlauben.', + 'Matrix-Client-API von dieser Seite aus nicht erreichbar. Bei ' + + 'oeffentlichen Servern https:// verwenden (kein Klartext-http). ' + + 'Netzwerk pruefen. Manche Betreiberschraenken zusaetzlich, welche ' + + 'Website-Urspruenge Login-Calls erlauben—auch wenn die ' + + 'Homeserver-URL stimmt.', 'auth.restoringSession': 'Session wird wiederhergestellt...', 'chat.loggedInAs': 'Eingeloggt als', 'chat.signOut': 'Abmelden', @@ -366,6 +445,18 @@ const messages: Record> = { 'settings.verificationMismatch': 'Verifizierung abgebrochen, weil Emojis nicht uebereinstimmen.', 'settings.verificationCancelled': 'Verifizierung wurde abgebrochen.', 'settings.verificationCrossSigningHint': 'Cross-Signing muss eventuell zuerst im anderen Client eingerichtet werden.', + 'settings.verificationNeedCrossSigning': + 'Cross-Signing fuer dieses Konto ist noch nicht verfuegbar; richte ihn ' + + 'zuerst in einem anderen Matrix-Client ein.', + 'settings.verificationReadyTimeout': + 'Timeout beim Warten auf den anderen Client. Offne die andere Session, ' + + 'akzeptiere die Verifizierung und versuche es erneut.', + 'settings.verificationUnknownOtherDevice': + 'Das andere Geraet konnte noch nicht geladen werden. Stelle sicher, ' + + 'dass der andere Client online ist, tippe auf Status aktualisieren, ' + + 'und versuche es erneut.', + 'settings.verificationProtocolError': + 'Die Verifizierung konnte nicht fortgesetzt werden — abbrechen und erneut starten.', 'settings.backToChat': 'Zurück zum Chat', 'settings.spaceTitle': 'Space-Einstellungen', 'settings.spaceDescription': 'Basis-Einstellungen für diesen Space.', @@ -465,12 +556,20 @@ export function useAppI18n() { } } - function translateText(key: string): string { - return ( + function translateText( + key: string, + placeholders?: Record + ): string { + let text = messages[locale.value][key] ?? messages.en[key] ?? key - ) + if (placeholders) { + for (const [ph, value] of Object.entries(placeholders)) { + text = text.replaceAll(`{${ph}}`, value) + } + } + return text } return { diff --git a/app/composables/useMatrixClient.ts b/app/composables/useMatrixClient.ts index e314b27..6cda55e 100644 --- a/app/composables/useMatrixClient.ts +++ b/app/composables/useMatrixClient.ts @@ -8,18 +8,25 @@ import { Preset, Visibility } from 'matrix-js-sdk' +import { CryptoEvent } from 'matrix-js-sdk/lib/crypto-api' import { initAsync as initCryptoWasm } from '@matrix-org/matrix-sdk-crypto-wasm' +import { readonly, shallowRef } from 'vue' import { extractUserLocalpart, - isLikelyBrowserNetworkOrCorsError, + HOMESERVER_CONNECTION_HINT_ERROR, + isTransportFailureWithoutMatrixBody, isSameHomeserver, readMatrixErrorCode, readMatrixErrorMessage, isSignupUnsupported, resolveHomeserverBaseUrlForClient } from './matrix/matrixClientShared' -export { HOMESERVER_CONNECTION_HINT_ERROR, isSameHomeserver, resolveHomeserverBaseUrlForClient } from './matrix/matrixClientShared' +export { + HOMESERVER_CONNECTION_HINT_ERROR, + isSameHomeserver, + resolveHomeserverBaseUrlForClient +} from './matrix/matrixClientShared' import { clearSignupPending, finalizeEmailRegistration, @@ -39,6 +46,7 @@ import { SIGNUP_PENDING_STORAGE_KEY, SIGNUP_RECAPTCHA_FAILED, SIGNUP_RECAPTCHA_TOKEN_REQUIRED, + SIGNUP_REGISTER_API_CLOSED_ERROR, SIGNUP_REGISTRATION_UNSUPPORTED_STAGE, SIGNUP_REGISTRATION_TOKEN_REJECTED, SIGNUP_REGISTRATION_TOKEN_REQUIRED, @@ -67,6 +75,7 @@ export { SIGNUP_PENDING_STORAGE_KEY, SIGNUP_RECAPTCHA_FAILED, SIGNUP_RECAPTCHA_TOKEN_REQUIRED, + SIGNUP_REGISTER_API_CLOSED_ERROR, SIGNUP_REGISTRATION_UNSUPPORTED_STAGE, SIGNUP_REGISTRATION_TOKEN_REJECTED, SIGNUP_REGISTRATION_TOKEN_REQUIRED, @@ -92,11 +101,37 @@ export { isTermsStage } from './matrix/matrixRegistrationUia' +import { + exchangeNativeOidcAuthorizationCode, + fetchMatrixWhoAmI, + MATRIX_DELEGATED_OIDC_CALLBACK_RELATIVE_PATH, + MATRIX_OIDC_HTTPS_ORIGIN_REQUIRED_ERROR, + MATRIX_OIDC_INVALID_CALLBACK_ERROR, + redirectToMatrixNativeOidc, + refreshNativeOidcAccessToken, + resolveTrustedAppHttpsOrigin, + type MatrixOidcIntent +} from './matrix/matrixOidcNative' + +export { + MATRIX_DELEGATED_OIDC_CALLBACK_RELATIVE_PATH, + fetchMatrixDelegatedClientHints, + MATRIX_OIDC_HTTPS_ORIGIN_REQUIRED_ERROR, + MATRIX_OIDC_INVALID_CALLBACK_ERROR, + MATRIX_OIDC_NO_DELEGATED_AUTH_ERROR, + MATRIX_OIDC_REGISTRATION_REJECTED_ERROR, + MATRIX_OIDC_STATE_STORAGE_PREFIX +} from './matrix/matrixOidcNative' + interface StoredMatrixSession { baseUrl: string accessToken: string userId: string deviceId?: string + refreshToken?: string + oauthTokenExpiresAtMs?: number + oidcTokenEndpoint?: string + oidcClientId?: string } interface StoredMatrixDevice { @@ -110,6 +145,94 @@ type SessionRestoreStatus = 'idle' | 'loading' | 'success' | 'failure' const MATRIX_SESSION_STORAGE_KEY = 'decentra.matrix.session.v1' const MATRIX_DEVICE_STORAGE_KEY = 'decentra.matrix.device.v1' let cryptoWasmInitialization: Promise | null = null + +/** MatrixClient started verification from another own device → open SAS flow */ +const incomingVerificationFromOtherOwnDevice = shallowRef(false) + +/** @returns Whether a beacon was consumed (caller may start SAS flow once). */ +export function consumeIncomingVerificationFromOtherOwnDeviceBeacon(): boolean { + if (!incomingVerificationFromOtherOwnDevice.value) { + return false + } + incomingVerificationFromOtherOwnDevice.value = false + return true +} + +/** Read-only: verification request initiated from another own device/tab */ +export function getIncomingVerificationFromOtherOwnDeviceReadonly() { + return readonly(incomingVerificationFromOtherOwnDevice) +} + +let verificationRelayAttachedClient: MatrixClient | null = null + +function onMatrixVerificationRelayRequestReceived( + request: unknown +): void { + if (!request || typeof request !== 'object') { + return + } + const candidate = request as { + isSelfVerification?: boolean + pending?: boolean + initiatedByMe?: boolean + } + if ( + candidate.isSelfVerification && + candidate.pending && + candidate.initiatedByMe === false + ) { + incomingVerificationFromOtherOwnDevice.value = true + } +} + +/** + * Incoming SAS from another signed-in device (MSC re-emitted onto MatrixClient). + * Call after initRustCrypto; detach on logout/replace client. + */ +export function syncMatrixIncomingVerificationRelay( + matrixClient: MatrixClient | null +): void { + if (verificationRelayAttachedClient === matrixClient && matrixClient) { + return + } + if (verificationRelayAttachedClient) { + if ( + typeof verificationRelayAttachedClient.removeListener === 'function' + ) { + verificationRelayAttachedClient.removeListener( + CryptoEvent.VerificationRequestReceived, + onMatrixVerificationRelayRequestReceived + ) + } + verificationRelayAttachedClient = null + } + if (!matrixClient) { + return + } + if (typeof matrixClient.on !== 'function') { + return + } + verificationRelayAttachedClient = matrixClient + matrixClient.on( + CryptoEvent.VerificationRequestReceived, + onMatrixVerificationRelayRequestReceived + ) +} + +async function bootstrapRustCrossSigningIfNeeded( + matrixClient: MatrixClient +): Promise { + const cryptoApi = matrixClient.getCrypto?.() + if (!cryptoApi || typeof cryptoApi.bootstrapCrossSigning !== 'function') { + return + } + try { + await cryptoApi.bootstrapCrossSigning({}) + } catch { + // Interactive auth may be required on some homeservers; ignore silently. + } +} + let sessionRestorePromise: Promise | null = null interface MatrixEncryptedFile { @@ -487,6 +610,7 @@ export function useMatrixClient() { try { await ensureCryptoWasmInitialized() await matrixClient.initRustCrypto() + await bootstrapRustCrossSigningIfNeeded(matrixClient) return true } catch (error) { if (!isCryptoStoreAccountMismatch(error)) { @@ -503,6 +627,7 @@ export function useMatrixClient() { try { await ensureCryptoWasmInitialized() await matrixClient.initRustCrypto() + await bootstrapRustCrossSigningIfNeeded(matrixClient) return true } catch (retryError) { console.error( @@ -591,10 +716,42 @@ export function useMatrixClient() { if (client.value) { return } - const session = readStoredSession() + let session = readStoredSession() if (!session) { return } + if ( + session.refreshToken && + session.oidcTokenEndpoint && + session.oidcClientId && + session.oauthTokenExpiresAtMs != null + ) { + const nearingExpiry = + Date.now() > session.oauthTokenExpiresAtMs - 120_000 + if (nearingExpiry) { + try { + const renewed = await refreshNativeOidcAccessToken({ + refreshToken: session.refreshToken, + tokenEndpoint: session.oidcTokenEndpoint, + clientId: session.oidcClientId + }) + let expiresMs = session.oauthTokenExpiresAtMs + if (renewed.expiresInSeconds != null) { + expiresMs = + Date.now() + renewed.expiresInSeconds * 1000 + } + session = { + ...session, + accessToken: renewed.accessToken, + refreshToken: renewed.refreshToken || session.refreshToken, + oauthTokenExpiresAtMs: expiresMs + } + writeStoredSession(session) + } catch { + // Keep previous access_token; renewal can fail offline. + } + } + } const restoredClient = sdk.createClient({ baseUrl: session.baseUrl, accessToken: session.accessToken, @@ -606,6 +763,7 @@ export function useMatrixClient() { } restoredClient.startClient({ initialSyncLimit: 50 }) client.value = restoredClient + syncMatrixIncomingVerificationRelay(restoredClient) } function startSessionRestore(): Promise { @@ -693,6 +851,7 @@ export function useMatrixClient() { newClient.startClient({ initialSyncLimit: 50 }) client.value = newClient + syncMatrixIncomingVerificationRelay(newClient) writeStoredSession({ baseUrl: resolvedBaseUrl, accessToken: authData.access_token, @@ -707,7 +866,7 @@ export function useMatrixClient() { }) } } catch (error) { - if (isLikelyBrowserNetworkOrCorsError(error)) { + if (isTransportFailureWithoutMatrixBody(error)) { throw new Error(HOMESERVER_CONNECTION_HINT_ERROR) } const matrixMessage = readMatrixErrorMessage(error) @@ -738,6 +897,7 @@ export function useMatrixClient() { } function logout(): void { + syncMatrixIncomingVerificationRelay(null) if (client.value) { client.value.stopClient() client.value = null @@ -745,6 +905,85 @@ export function useMatrixClient() { clearStoredSession() } + function resolveConfiguredTrustedSiteOrigin(): string { + const runtimeCfg = useRuntimeConfig() + return resolveTrustedAppHttpsOrigin( + String(runtimeCfg.public.siteUrl || '').trim() + ) + } + + async function startDelegatedMatrixNativeOidcAuth(payload: { + homeserverUrlInput: string + intent: MatrixOidcIntent + }): Promise { + const siteOriginHttps = resolveConfiguredTrustedSiteOrigin() + if (!siteOriginHttps) { + throw new Error(MATRIX_OIDC_HTTPS_ORIGIN_REQUIRED_ERROR) + } + const runtimeCfg = useRuntimeConfig() + await redirectToMatrixNativeOidc({ + homeserverUrlInput: payload.homeserverUrlInput, + trustedAppHttpsOrigin: siteOriginHttps, + runtimeClientIdConfigured: String( + runtimeCfg.public.matrixOidcClientId || '' + ).trim(), + callbackPath: MATRIX_DELEGATED_OIDC_CALLBACK_RELATIVE_PATH, + intent: payload.intent + }) + } + + async function finalizeDelegatedMatrixOidcFromRedirectPayload( + payload: { code: string; state: string } + ): Promise { + if (typeof window === 'undefined') { + throw new Error(MATRIX_OIDC_INVALID_CALLBACK_ERROR) + } + const exchanged = await exchangeNativeOidcAuthorizationCode({ + code: payload.code, + state: payload.state + }) + const identity = await fetchMatrixWhoAmI( + exchanged.pending.matrixClientApiBaseUrl, + exchanged.tokens.accessToken + ) + const ttlSeconds = exchanged.tokens.expiresInSeconds ?? 300 + const oauthExpiresAtMs = Date.now() + ttlSeconds * 1000 + const deviceLit = exchanged.pending.oidcDeviceId + const matrixApiBase = exchanged.pending.matrixClientApiBaseUrl + const delegatedClient = sdk.createClient({ + baseUrl: matrixApiBase, + accessToken: exchanged.tokens.accessToken, + userId: identity.userId, + deviceId: deviceLit + }) + await initRustCryptoWithRecovery( + delegatedClient, + 'during delegated OIDC' + ) + delegatedClient.startClient({ initialSyncLimit: 50 }) + if (client.value) { + client.value.stopClient() + } + syncMatrixIncomingVerificationRelay(delegatedClient) + client.value = delegatedClient + const maybeRefresh = exchanged.tokens.refreshToken ?? undefined + writeStoredSession({ + baseUrl: matrixApiBase, + accessToken: exchanged.tokens.accessToken, + userId: identity.userId, + deviceId: deviceLit, + refreshToken: maybeRefresh, + oauthTokenExpiresAtMs: oauthExpiresAtMs, + oidcTokenEndpoint: exchanged.pending.tokenEndpoint, + oidcClientId: exchanged.pending.oauthClientId + }) + writeStoredDevice({ + baseUrl: matrixApiBase, + userId: identity.userId, + deviceId: deviceLit + }) + } + async function ensureCryptoReady(): Promise { const matrixClient = client.value if (!matrixClient) { @@ -1007,7 +1246,7 @@ export function useMatrixClient() { await mergeDirectAccountData(matrixClient, peerUserId, roomId) return roomId } catch (error) { - if (isLikelyBrowserNetworkOrCorsError(error)) { + if (isTransportFailureWithoutMatrixBody(error)) { throw new Error(HOMESERVER_CONNECTION_HINT_ERROR) } throwMappedMatrixError(error, 'Could not start direct message') @@ -1078,7 +1317,7 @@ export function useMatrixClient() { const { room_id: roomId } = await matrixClient.createRoom(createOpts) return roomId } catch (error) { - if (isLikelyBrowserNetworkOrCorsError(error)) { + if (isTransportFailureWithoutMatrixBody(error)) { throw new Error(HOMESERVER_CONNECTION_HINT_ERROR) } throwMappedMatrixError(error, 'Could not create room') @@ -1095,7 +1334,7 @@ export function useMatrixClient() { const room = await matrixClient.joinRoom(trimmed, {}) return room.roomId } catch (error) { - if (isLikelyBrowserNetworkOrCorsError(error)) { + if (isTransportFailureWithoutMatrixBody(error)) { throw new Error(HOMESERVER_CONNECTION_HINT_ERROR) } throwMappedMatrixError(error, 'Could not join room') @@ -1146,7 +1385,7 @@ export function useMatrixClient() { totalRoomCountEstimate: response.total_room_count_estimate } } catch (error) { - if (isLikelyBrowserNetworkOrCorsError(error)) { + if (isTransportFailureWithoutMatrixBody(error)) { throw new Error(HOMESERVER_CONNECTION_HINT_ERROR) } throwMappedMatrixError( @@ -1176,7 +1415,7 @@ export function useMatrixClient() { avatarUrl: row.avatar_url })) } catch (error) { - if (isLikelyBrowserNetworkOrCorsError(error)) { + if (isTransportFailureWithoutMatrixBody(error)) { throw new Error(HOMESERVER_CONNECTION_HINT_ERROR) } throwMappedMatrixError( @@ -1203,6 +1442,8 @@ export function useMatrixClient() { signupPendingNeedsRecaptchaBeforeEmail, readSignupPendingPublic, logout, + startDelegatedMatrixNativeOidcAuth, + finalizeDelegatedMatrixOidcFromRedirectPayload, getRooms, getRoom, sendMessage, @@ -1220,6 +1461,9 @@ export function useMatrixClient() { createGroupRoom, joinRoomByIdOrAlias, searchPublicRooms, - searchUsersDirectory + searchUsersDirectory, + incomingVerificationFromOtherOwnDeviceBeacon: + getIncomingVerificationFromOtherOwnDeviceReadonly(), + consumeIncomingVerificationFromOtherOwnDeviceBeacon } } diff --git a/app/pages/auth/matrix-oidc/callback.vue b/app/pages/auth/matrix-oidc/callback.vue new file mode 100644 index 0000000..d82052b --- /dev/null +++ b/app/pages/auth/matrix-oidc/callback.vue @@ -0,0 +1,108 @@ + + + diff --git a/app/pages/login.vue b/app/pages/login.vue index b8130d0..3c7f676 100644 --- a/app/pages/login.vue +++ b/app/pages/login.vue @@ -58,7 +58,9 @@ function clearForm() {
-
+
-
+
+ - - - - - - - - -
- - {{ translateText('cancel') }} - - - {{ translateText('auth.signIn') }} - -
- +
+ + + + + + + + +
+ + {{ translateText('cancel') }} + + + {{ translateText('auth.signIn') }} + +
+
+
diff --git a/app/pages/settings/account.vue b/app/pages/settings/account.vue index 93ca782..e2b708f 100644 --- a/app/pages/settings/account.vue +++ b/app/pages/settings/account.vue @@ -1,6 +1,36 @@