diff --git a/src/api/matrixclient.ts b/src/api/matrixclient.ts index 4d028ae..fd7c53b 100644 --- a/src/api/matrixclient.ts +++ b/src/api/matrixclient.ts @@ -1,5 +1,6 @@ import { ReqLogin, + RespAuthMetadata, RespLogin, RespLoginFlows, RespOpenIDToken, @@ -67,6 +68,10 @@ export class MatrixClient extends BaseAPIClient { return this.request("GET", "/v3/login") } + getAuthMetadata(): Promise { + return this.request("GET", "/v1/auth_metadata") + } + login(req: ReqLogin): Promise { return this.request("POST", "/v3/login", req) } diff --git a/src/api/oauth.ts b/src/api/oauth.ts new file mode 100644 index 0000000..38dac56 --- /dev/null +++ b/src/api/oauth.ts @@ -0,0 +1,178 @@ +import type { LoginClientHTTPResponse } from "../types/loginstep" +import type { + RespAuthMetadata, + RespClientRegistration, + RespDeviceAuthorization, + RespLogin, + RespOAuthToken, +} from "../types/matrix" +import { MatrixClient } from "./matrixclient" + +const clientMetadata = { + client_name: "mautrix-manager", + client_uri: "https://github.com/mautrix/manager", + application_type: "native", + token_endpoint_auth_method: "none", + grant_types: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"], + response_types: [], +} + +export interface DeviceLoginInfo { + userCode: string + verificationURI: string +} + +function oauthError(status: number, body: unknown): Error { + const err = body as { error?: string, error_description?: string } + const code = err.error ?? `HTTP ${status}` + return new Error(err.error_description ? `${code}: ${err.error_description}` : code) +} + +function decodeBody(resp: LoginClientHTTPResponse): unknown { + if ("error" in resp) { + throw new Error(resp.error) + } + if (!resp.body) { + return {} + } + return JSON.parse(new TextDecoder().decode(Uint8Array.fromBase64(resp.body))) +} + +async function postForm(url: string, params: Record): Promise<[number, unknown]> { + const resp = await window.mautrixAPI.doClientHTTP(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + body: new URLSearchParams(params).toString(), + }) + if ("error" in resp) { + throw new Error(resp.error) + } + return [resp.status_code, decodeBody(resp)] +} + +async function postJSON(url: string, data: unknown): Promise<[number, unknown]> { + const resp = await window.mautrixAPI.doClientHTTP(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify(data), + }) + if ("error" in resp) { + throw new Error(resp.error) + } + return [resp.status_code, decodeBody(resp)] +} + +function randomDeviceID(): string { + const bytes = new Uint8Array(8) + crypto.getRandomValues(bytes) + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + return Array.from(bytes, b => alphabet[b % alphabet.length]).join("") +} + +function clientStorageKey(issuer: string): string { + return `oauth_client_id_${issuer}` +} + +async function getClientID(metadata: RespAuthMetadata): Promise { + const cached = localStorage[clientStorageKey(metadata.issuer)] + if (cached) { + return cached + } + if (!metadata.registration_endpoint) { + throw new Error("Homeserver's auth server doesn't support dynamic client registration") + } + const [status, body] = await postJSON(metadata.registration_endpoint, clientMetadata) + if (status >= 400) { + throw oauthError(status, body) + } + const clientID = (body as RespClientRegistration).client_id + if (!clientID) { + throw new Error("Client registration response didn't include a client_id") + } + localStorage[clientStorageKey(metadata.issuer)] = clientID + return clientID +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +async function pollForToken( + metadata: RespAuthMetadata, + clientID: string, + deviceCode: string, + intervalSeconds: number, + expiresInSeconds: number, +): Promise { + const deadline = Date.now() + expiresInSeconds * 1000 + let interval = Math.max(intervalSeconds, 1) + while (Date.now() < deadline) { + await sleep(interval * 1000) + const [status, body] = await postForm(metadata.token_endpoint, { + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: deviceCode, + client_id: clientID, + }) + if (status < 400) { + return body as RespOAuthToken + } + const err = body as { error?: string, error_description?: string } + if (err.error === "authorization_pending") { + continue + } else if (err.error === "slow_down") { + interval += 5 + continue + } + throw oauthError(status, body) + } + throw new Error("Timed out waiting for the login to be approved") +} + +export async function loginWithDeviceCode( + matrixClient: MatrixClient, + onDeviceLogin: (info: DeviceLoginInfo) => void, +): Promise { + const metadata = await matrixClient.getAuthMetadata() + if (!metadata.device_authorization_endpoint) { + throw new Error("Homeserver's auth server doesn't support the device authorization grant") + } + const clientID = await getClientID(metadata) + const deviceID = randomDeviceID() + const scope = `urn:matrix:client:api:* urn:matrix:client:device:${deviceID}` + + const [status, body] = await postForm(metadata.device_authorization_endpoint, { + client_id: clientID, + scope, + }) + if (status >= 400) { + throw oauthError(status, body) + } + const deviceAuth = body as RespDeviceAuthorization + const verificationURI = deviceAuth.verification_uri_complete ?? deviceAuth.verification_uri + onDeviceLogin({ userCode: deviceAuth.user_code, verificationURI: deviceAuth.verification_uri }) + window.mautrixAPI.openInBrowser(verificationURI).catch(() => undefined) + + const token = await pollForToken( + metadata, + clientID, + deviceAuth.device_code, + deviceAuth.interval ?? 5, + deviceAuth.expires_in ?? 300, + ) + + const authedClient = new MatrixClient(matrixClient.baseURL, undefined, token.access_token) + const whoami = await authedClient.whoami() + return { + access_token: token.access_token, + device_id: whoami.device_id ?? deviceID, + user_id: whoami.user_id, + refresh_token: token.refresh_token, + expires_in_ms: token.expires_in ? token.expires_in * 1000 : undefined, + } +} diff --git a/src/app/MatrixLogin.css b/src/app/MatrixLogin.css index 7f67ffd..f972ac5 100644 --- a/src/app/MatrixLogin.css +++ b/src/app/MatrixLogin.css @@ -64,3 +64,14 @@ main.matrix-login { padding: 1rem; } } + +.oauth-device-login { + max-width: 20rem; + text-align: center; + line-height: 1.5; +} + +.oauth-device-login code { + font-weight: bold; + user-select: all; +} diff --git a/src/app/MatrixLogin.tsx b/src/app/MatrixLogin.tsx index 7b6ab69..272d895 100644 --- a/src/app/MatrixLogin.tsx +++ b/src/app/MatrixLogin.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react" import type { MatrixClient } from "../api/matrixclient" -import type { RespClientWellKnown, RespLogin } from "../types/matrix" +import { type DeviceLoginInfo, loginWithDeviceCode } from "../api/oauth" +import type { RespAuthMetadata, RespClientWellKnown, RespLogin } from "../types/matrix" import "./MatrixLogin.css" interface LoginScreenProps { @@ -17,6 +18,9 @@ const LoginScreen = ({ const [password, setPassword] = useState("") const [error, setError] = useState("") const [loginFlows, setLoginFlows] = useState | null>(null) + const [authMetadata, setAuthMetadata] = useState(null) + const [deviceLogin, setDeviceLogin] = useState(null) + const [oauthInProgress, setOAuthInProgress] = useState(false) const forceResolveImmediate = useRef(true) const login = useCallback((evt: React.FormEvent) => { @@ -41,17 +45,50 @@ const LoginScreen = ({ .catch(err => window.alert(`Failed to open SSO URL in browser: ${err}`)) } }, [matrixClient]) + const loginOAuth = useCallback(() => { + setError("") + setDeviceLogin(null) + setOAuthInProgress(true) + loginWithDeviceCode(matrixClient, setDeviceLogin).then( + resp => { + setOAuthInProgress(false) + onLoggedIn(resp) + }, + err => { + setOAuthInProgress(false) + setDeviceLogin(null) + setError(`OAuth login failed: ${err}`) + }, + ) + }, [matrixClient, onLoggedIn]) const resolveHomeserver = useCallback(() => { if (homeserverURL.startsWith("https://") || homeserverURL.startsWith("http://")) { matrixClient.getLoginFlows().then( resp => { setLoginFlows(new Set(resp.flows.map(flow => flow.type))) + setAuthMetadata(null) setError("") }, - err => { - setLoginFlows(null) - setError(`Failed to get login flows: ${err}`) + flowErr => { + matrixClient.getAuthMetadata().then( + meta => { + if (meta.device_authorization_endpoint) { + setLoginFlows(null) + setAuthMetadata(meta) + setError("") + } else { + setLoginFlows(null) + setAuthMetadata(null) + setError(`Failed to get login flows: ${flowErr}`) + } + }, + () => { + setLoginFlows(null) + setAuthMetadata(null) + setError(`Failed to get login flows: ${flowErr}`) + }, + ) }, ) } else if (homeserverURL) { @@ -97,9 +134,10 @@ const LoginScreen = ({ onChange={evt => { setHomeserverURL(evt.target.value) setLoginFlows(null) + setAuthMetadata(null) }} /> - {!loginFlows?.size &&
} + {!loginFlows?.size && !authMetadata &&
} {loginFlows?.has("m.login.password") &&
} + {authMetadata && <> + + {deviceLogin &&
+ Approve the login at + {deviceLogin.verificationURI} + using the code {deviceLogin.userCode}. +
} + } {error &&
{error}
} diff --git a/src/types/matrix.ts b/src/types/matrix.ts index 604ef0c..70ce041 100644 --- a/src/types/matrix.ts +++ b/src/types/matrix.ts @@ -91,3 +91,37 @@ export interface RespLogin { expires_in_ms?: number well_known?: RespClientWellKnown } + +export interface RespAuthMetadata { + issuer: string + authorization_endpoint: string + token_endpoint: string + registration_endpoint?: string + device_authorization_endpoint?: string + revocation_endpoint?: string + grant_types_supported?: string[] + code_challenge_methods_supported?: string[] + response_types_supported?: string[] +} + +export interface RespClientRegistration { + client_id: string + client_id_issued_at?: number +} + +export interface RespDeviceAuthorization { + device_code: string + user_code: string + verification_uri: string + verification_uri_complete?: string + expires_in: number + interval?: number +} + +export interface RespOAuthToken { + access_token: string + token_type: string + expires_in?: number + refresh_token?: string + scope?: string +}