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 @@
+
+
+
+
+
+
+
+
+
+ {{ translateText('auth.matrixOidcCallbackTitle') }}
+
+
+
+ {{ translateText('auth.matrixOidcCallbackBusy') }}
+
+
+
+ {{ translateText('auth.matrixOidcBackToLogin') }}
+
+
+
+
+
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() {