Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/api/matrixclient.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ReqLogin,
RespAuthMetadata,
RespLogin,
RespLoginFlows,
RespOpenIDToken,
Expand Down Expand Up @@ -67,6 +68,10 @@ export class MatrixClient extends BaseAPIClient {
return this.request("GET", "/v3/login")
}

getAuthMetadata(): Promise<RespAuthMetadata> {
return this.request("GET", "/v1/auth_metadata")
}

login(req: ReqLogin): Promise<RespLogin> {
return this.request("POST", "/v3/login", req)
}
Expand Down
178 changes: 178 additions & 0 deletions src/api/oauth.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): 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<string> {
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<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

async function pollForToken(
metadata: RespAuthMetadata,
clientID: string,
deviceCode: string,
intervalSeconds: number,
expiresInSeconds: number,
): Promise<RespOAuthToken> {
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<RespLogin> {
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,
}
}
11 changes: 11 additions & 0 deletions src/app/MatrixLogin.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
58 changes: 53 additions & 5 deletions src/app/MatrixLogin.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -17,6 +18,9 @@ const LoginScreen = ({
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [loginFlows, setLoginFlows] = useState<Set<string> | null>(null)
const [authMetadata, setAuthMetadata] = useState<RespAuthMetadata | null>(null)
const [deviceLogin, setDeviceLogin] = useState<DeviceLoginInfo | null>(null)
const [oauthInProgress, setOAuthInProgress] = useState(false)
const forceResolveImmediate = useRef(true)

const login = useCallback((evt: React.FormEvent) => {
Expand All @@ -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) {
Expand Down Expand Up @@ -97,9 +134,10 @@ const LoginScreen = ({
onChange={evt => {
setHomeserverURL(evt.target.value)
setLoginFlows(null)
setAuthMetadata(null)
}}
/>
{!loginFlows?.size && <div tabIndex={0} className="tab-catcher" />}
{!loginFlows?.size && !authMetadata && <div tabIndex={0} className="tab-catcher" />}
{loginFlows?.has("m.login.password") && <form onSubmit={login}>
<input
type="text"
Expand All @@ -120,6 +158,16 @@ const LoginScreen = ({
{loginFlows?.has("m.login.sso") && <>
<button className="mx-login-button" onClick={loginSSO}>SSO login</button>
</>}
{authMetadata && <>
<button className="mx-login-button" onClick={loginOAuth} disabled={oauthInProgress}>
{oauthInProgress ? "Waiting for approval…" : "Login with your homeserver"}
</button>
{deviceLogin && <div className="oauth-device-login">
Approve the login at <a href={deviceLogin.verificationURI} target="_blank" rel="noreferrer">
{deviceLogin.verificationURI}
</a> using the code <code>{deviceLogin.userCode}</code>.
</div>}
</>}
{error && <div className="error">
{error}
</div>}
Expand Down
34 changes: 34 additions & 0 deletions src/types/matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}