diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e125f4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:14-alpine as builder + +ARG DEFAULT_HOMESERVER + +WORKDIR /files-sdk-demo + +COPY .npmrc yarn.lock package.json ./ +RUN yarn install --frozen-lockfile + +COPY src ./src +COPY public ./public +COPY tsconfig.json webpack.config.js webpack.parts.js ./ +ENV DEFAULT_HOMESERVER ${DEFAULT_HOMESERVER} +RUN yarn build + +FROM nginxinc/nginx-unprivileged:1.21-alpine + +COPY --from=builder /files-sdk-demo/dist /usr/share/nginx/html diff --git a/package.json b/package.json index d9e1718..e9aadbd 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,10 @@ }, "devDependencies": { "@tsconfig/svelte": "^3.0.0", + "@types/crypto-js": "^4.1.1", "@types/mime-types": "^2.1.1", "@types/page": "^1.11.5", + "@types/uuid": "^8.3.4", "clean-webpack-plugin": "^4.0.0", "copy-webpack-plugin": "^9.0.1", "css-loader": "^6.5.1", @@ -48,6 +50,7 @@ "@material/typography": "^13.0.0", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.13.tgz", "@smui/button": "^5.0.1", + "@smui/card": "^5.0.1", "@smui/data-table": "^5.0.1", "@smui/fab": "^5.0.1", "@smui/icon-button": "^5.0.1", @@ -69,6 +72,7 @@ "matrix-files-sdk": "^3.0.2", "matrix-js-sdk": "^24.1.0", "mime-types": "^2.1.34", + "oidc-client-ts": "hughns/oidc-client-ts#08ac93b33fd1b021b6771c80397bbc6754fa3b8c", "page": "^1.11.6", "svelte": "^3.49.0", "svelte-accordion": "^0.0.0-development", @@ -76,6 +80,8 @@ "svelte-loading-skeleton": "^1.0.1", "svelte-material-ui": "^5.0.1", "svelte-preprocess": "^4.10.1", - "svelte-toasts": "^1.1.2" + "svelte-qrcode": "^1.0.0", + "svelte-toasts": "^1.1.2", + "uuid": "^8.3.2" } } diff --git a/public/logo.svg b/public/logo.svg index 99f2dfa..813f599 100644 --- a/public/logo.svg +++ b/public/logo.svg @@ -1,21 +1,19 @@ - - Slice 1 + + Untitled 5 - \ No newline at end of file diff --git a/src/ClientManager.ts b/src/ClientManager.ts index 010956d..c251ca1 100644 --- a/src/ClientManager.ts +++ b/src/ClientManager.ts @@ -19,20 +19,110 @@ import { loginWithPassword, createFromToken, registerWithPassword } from "./auth import { readValue, storeValue } from "./storage"; import { MatrixCrypto } from "./MatrixCrypto"; import { SimpleObservable } from "./external/SimpleObservable"; -import { HttpApiEvent } from "matrix-js-sdk/lib"; +import { HttpApiEvent, MatrixError } from "matrix-js-sdk/lib"; +import { toasts } from "svelte-toasts"; +import router from 'page'; +import { getLogger } from "log4js"; +import { + UserManager, MetadataService, OidcClientSettingsStore, type DeviceAuthorizationResponse, type OidcMetadata, +} from 'oidc-client-ts'; +import { v4 } from 'uuid'; + +const log = getLogger('ClientManager'); const defaultHomeserver = process.env.DEFAULT_HOMESERVER!; +type IssuerUri = string; +interface ClientConfig { + client_id: string; + client_secret?: string; +} + +// These are statically configured OIDC client IDs for particular issuers: +const clientIds: Record = { + "https://dev-6525741.okta.com/": { + client_id: "0oa5qpnjvfLILbe3W5d7", + }, + "http://localhost:8091/realms/master/": { + client_id: "files-sdk-demo" + }, + "https://keycloak-oidc.lab.element.dev/realms/master/": { + client_id: "files-sdk-demo" + }, +}; + export class ClientManager { private _files: MatrixFiles | undefined; private _crypto: MatrixCrypto | undefined; + private oidcIssuerMetadata?: Partial; + private deviceFlow?: DeviceAuthorizationResponse; public readonly authedState = new SimpleObservable(false); - public homeserverUrl = readValue("homeserverUrl", defaultHomeserver); - public accessToken = readValue("accessToken", ''); - public deviceId = readValue("deviceId", ''); - public userId = readValue("userId", ''); + public get homeserverUrl(): string { + return readValue("homeserverUrl", defaultHomeserver); + } + + public set homeserverUrl(val) { + storeValue("homeserverUrl", val); + } + + public get oidcIssuer(): string { + return readValue("oidcIssuer", ''); + } + + public set oidcIssuer(val) { + storeValue("oidcIssuer", val); + } + + public get oidcClientIssuer(): string { + return readValue("oidcClientIssuer", ''); + } + + public set oidcClientIssuer(val) { + storeValue("oidcClientIssuer", val); + } + + public get oidcClientId(): string { + return readValue("oidcClientId", ''); + } + + public set oidcClientId(val) { + storeValue("oidcClientId", val); + } + + public get oidcClientSecret(): string | undefined { + return readValue("oidcClientSecret", undefined); + } + + public set oidcClientSecret(val) { + storeValue("oidcClientSecret", val); + } + + public get accessToken(): string { + return readValue("accessToken", ''); + } + + public set accessToken(val) { + storeValue("accessToken", val); + } + + public get deviceId(): string { + return readValue("deviceId", ''); + } + + public set deviceId(val) { + storeValue("deviceId", val); + } + + public get userId(): string { + return readValue("userId", ''); + } + + public set userId(val) { + storeValue("userId", val); + } + public password: string = ''; public keyBackupPassphrase: string = ''; @@ -59,23 +149,282 @@ export class ClientManager { return this._crypto; } + private userManager: UserManager | undefined; + + private grant_types_supported: string[] = []; + + private async getIssuerMetadata() { + if (!this.oidcIssuerMetadata || this.oidcIssuer !== this.oidcIssuerMetadata.issuer) { + this.oidcIssuerMetadata = await (new MetadataService(new OidcClientSettingsStore({ + authority: this.oidcIssuer, + redirect_uri: 'notused', + client_id: 'notused', + }))).getMetadata(); + const { + grant_types_supported, device_authorization_endpoint, authorization_endpoint, + } = this.oidcIssuerMetadata; + if (grant_types_supported) { + this.grant_types_supported = grant_types_supported; + } else { + this.grant_types_supported = []; + if (authorization_endpoint) { + this.grant_types_supported.push("authorization_code"); + this.grant_types_supported.push("refresh_token"); + } + if (device_authorization_endpoint) { + this.grant_types_supported.push("urn:ietf:params:oauth:grant-type:device_code"); + } + } + } + return this.oidcIssuerMetadata; + } + + public async assertOidcClientId() { + const authority = this.authority; + + // use cached or pre-configured if available + if (clientIds[authority]) { + this.oidcClientId = clientIds[authority].client_id; + this.oidcClientSecret = clientIds[authority].client_secret; + this.oidcClientIssuer = authority; + log.info(`Using existing OIDC client_id ${this.oidcClientId} with issuer ${authority}`); + } else if (!this.oidcClientId || this.oidcClientIssuer !== authority) { + const { registration_endpoint } = await this.getIssuerMetadata(); + + if (!registration_endpoint) { + throw new Error('Unable to register with issuer'); + } + + // only ask for grants that are supported by the client and server + const grant_types = ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"].filter(x => this.grant_types_supported.includes(x)); + + if (grant_types.length === 0 || (grant_types.length === 1 && grant_types.includes("refresh_token"))) { + throw new Error('No supported authentication flow available'); + } + + log.info(`Attempting registration with OIDC issuer at ${registration_endpoint}`); + + const clientMetadata = { + client_name: "Files SDK Demo", + logo_uri: new URL("logo.svg", this.client_uri).href, + client_uri: this.client_uri, + tos_uri: "https://element.io/terms-of-service", + policy_uri: "https://element.io/privacy", + response_types: ["code"], + grant_types, + redirect_uris: [this.redirect_uri], + id_token_signed_response_alg: "RS256", + token_endpoint_auth_method: "none", + }; + + try { + const res = await fetch(registration_endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: "omit", + cache: 'no-cache', + body: JSON.stringify(clientMetadata), + }); + + const json = await res.json(); + + if (json.error) { + throw new Error(`${json.error}: ${json.error_description}`); + } + + // Cache the client details for subsequent use + clientIds[authority] = { + client_id: json.client_id, + client_secret: json.client_secret, + }; + + log.info(`Registered with OIDC issuer as ${json.client_id}`); + + // handle case of authority changing since we started registration: + if (authority === this.authority) { + this.oidcClientId = json.client_id; + this.oidcClientSecret = json.client_secret; + this.oidcClientIssuer = authority; + } + } catch (e: any) { + log.error(e); + throw new Error(`Unable to register with OIDC Provider (${authority}) - ${e?.message}`); + } + } + + if (!await this.hasUsableGrant()) { + throw new Error('No supported authentication flow available'); + } + } + + private static supportedGrants = ['authorization_code', 'urn:ietf:params:oauth:grant-type:device_code']; + + public async hasUsableGrant(): Promise { + await this.getIssuerMetadata(); + return !!this.grant_types_supported.some(x => ClientManager.supportedGrants.includes(x)); + } + + public async supportsDeviceCode(): Promise { + const { device_authorization_endpoint } = await this.getIssuerMetadata(); + return !!(this.grant_types_supported.includes('urn:ietf:params:oauth:grant-type:device_code') && device_authorization_endpoint); + } + + private get authority(): string { + return `${this.oidcIssuer}${this.oidcIssuer.endsWith('/') ? '' : '/'}`; + } + + private get client_uri(): string { + return document.location.origin + document.location.pathname; + } + + private get redirect_uri(): string { + return this.client_uri; + } + + private async getOidcUserManager() { + if (this.userManager && this.userManager.settings.authority !== this.oidcIssuer) { + log.info('Recreating OIDC UserManager as issuer changed'); + this.userManager.stopSilentRenew(); + this.userManager = undefined; + } + + if (!this.userManager) { + await this.assertOidcClientId(); + + if (!this.deviceId) { + this.deviceId = v4(); + } + + this.userManager = new UserManager({ + authority: this.authority, + client_id: this.oidcClientId, + client_secret: this.oidcClientSecret, + redirect_uri: this.redirect_uri, + accessTokenExpiringNotificationTimeInSeconds: 30, + scope: `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${this.deviceId}`, + }); + this.userManager.events.addUserLoaded(({ access_token, expires_in }) => { + log.debug(`Access token renewed with new expiry in ${expires_in}s`); + this.accessToken = access_token; + if (this._files) { + this._files.client.http.opts.accessToken = access_token; + } + }); + } + + return this.userManager; + } + public get hasAuthData(): boolean { + log.debug(`hasAuthData() homeserverUrl=${!!this.homeserverUrl} accessToken=${!!this.accessToken} deviceId=${!!this.deviceId} userId=${!!this.userId}`); return !!this.homeserverUrl && !!this.accessToken && !!this.deviceId && !!this.userId; } + private async wrapForbidden(f: () => Promise) { + try { + await f(); + } catch (e: any) { + log.error(e); + if (e instanceof MatrixError && e.errcode === 'M_FORBIDDEN') { + toasts.warning('You have been signed out', { duration: 5000 }); + await this._logout(); + router.redirect('/signin'); + } else { + throw e; + } + } + + } + public async rehydrate() { - this._files = await createFromToken(localStorage, this.homeserverUrl, this.accessToken, this.userId, this.deviceId); - await this.bootstrap(); + if (this.oidcIssuer) { + // initialise UserManager for refreshing tokens + await this.getOidcUserManager(); + } + await this.wrapForbidden(async () => { + this._files = await createFromToken(localStorage, this.homeserverUrl, this.accessToken, this.userId, this.deviceId); + await this.bootstrap(); + }); + } + + public async loginWithOidcNormalFlow() { + log.info('loginWithOidcNormalFlow()'); + const userManager = await this.getOidcUserManager(); + await userManager.signinRedirect(); + } + + public async startLoginWithOidcDeviceFlow(): Promise { + log.info('startLoginWithOidcDeviceFlow()'); + const userManager = await this.getOidcUserManager(); + this.deviceFlow = await userManager.startDeviceAuthorization(); + return this.deviceFlow; + } + + public async waitForLoginWithOidcDeviceFlow() { + log.info('waitForLoginWithOidcDeviceFlow()'); + if (!this.deviceFlow) { + throw new Error('Device flow not started'); + } + const userManager = await this.getOidcUserManager(); + const res = await userManager.waitForDeviceAuthorization(this.deviceFlow); + this.deviceFlow = undefined; + const { access_token } = res; + if (access_token) { + await this.whoami(access_token as string); + await this.rehydrate(); + } + router.redirect('/'); + return res; + } + + public async registerWithOidc() { + log.info('registerWithOidc()'); + await (await this.getOidcUserManager()).signinRedirect({ prompt: 'create' }); + } + + private async whoami(access_token: string) { + const url = new URL(this.homeserverUrl); + url.search = ''; + url.pathname = '/_matrix/client/v3/account/whoami'; + + const response = await fetch(url.href, { headers: { Authorization: `Bearer ${access_token}` } }); + + const { device_id, user_id } = await response.json(); + + log.info(`whoami() => device_id=${device_id} user_id=${user_id}`); + this.accessToken = access_token; + this.deviceId = device_id; + this.userId = user_id; + this.password = ''; } - private storeValues() { - storeValue("homeserverUrl", this.homeserverUrl); - storeValue("accessToken", this.accessToken); - storeValue("deviceId", this.deviceId); - storeValue("userId", this.userId); + public async completeOidcLogin() { + log.info('completeOidcLogin()'); + + const authority = this.oidcIssuer; + if (!authority) { + log.warn('Received OIDC code but no issuer available'); + } else { + const signinResponse = await (await this.getOidcUserManager()).signinCallback(); + if (signinResponse) { + const { access_token } = signinResponse; + + await this.whoami(access_token); + + // remove query params from current URL: + window.history.pushState('object', document.title, location.href.split("?")[0]); + + await this.rehydrate(); + + router.replace('/'); + } + } } - public async login() { + public async loginWithPassword() { + log.info('loginWithPassword()'); this._files = await loginWithPassword(localStorage, this.homeserverUrl, this.userId, this.password); this.homeserverUrl = this.client.getHomeserverUrl(); @@ -83,12 +432,11 @@ export class ClientManager { this.deviceId = this.client.deviceId ?? ''; this.userId = this.client.getUserId() ?? ''; - this.storeValues(); - - await this.bootstrap(); + await this.wrapForbidden(this.bootstrap); } public async register() { + log.info('register()'); this._files = await registerWithPassword(localStorage, this.homeserverUrl, this.userId, this.password); this.homeserverUrl = this.client.getHomeserverUrl(); @@ -96,9 +444,7 @@ export class ClientManager { this.deviceId = this.client.deviceId ?? ''; this.userId = this.client.getUserId() ?? ''; - this.storeValues(); - - await this.bootstrap(); + await this.wrapForbidden(this.bootstrap); } private async bootstrap() { @@ -109,31 +455,48 @@ export class ClientManager { console.log("Session.logged_out"); this._logout(); }); + // ping to check that session is valid + await this.client.whoami(); this._crypto = new MatrixCrypto(this._files.client); await this._crypto.init(); await this._files.sync(); + toasts.info(`${this.userId} logged in`, { duration: 5000 }); this.authedState.update(true); } - private _logout() { - this.homeserverUrl = defaultHomeserver; + private async _logout() { + // try { + // await logoutOidc(); + // } catch (e) { + // // it might be that it isn't initialised + // } this.userId = ''; this.keyBackupPassphrase = ''; this.password = ''; this.deviceId = ''; this.accessToken = ''; - this.storeValues(); this.authedState.update(false); } public async logout() { + log.info('logout()'); if (this._files) { - await this._files.logout(); + try { + await this._files.logout(); + } catch (e) { + log.warn(e); + } this._files = undefined; this._crypto = undefined; - localStorage.clear(); } - this._logout(); + if (this.oidcIssuer) { + // revoke access and refresh toeksn + const oidcUserManager = await this.getOidcUserManager(); + await oidcUserManager.revokeTokens(["access_token", "refresh_token"]); + } + localStorage.clear(); + sessionStorage.clear(); + await this._logout(); } on(event: string, handler: (...args: any[]) => void) { diff --git a/src/auth.ts b/src/auth.ts index 1b66ce1..f2a0ddf 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -18,6 +18,24 @@ import { createClient as createClientImpl, type ICreateClientOpts } from 'matrix import { LocalStorageCryptoStore } from 'matrix-js-sdk/lib/crypto/store/localStorage-crypto-store'; import { MatrixFiles } from 'matrix-files-sdk'; +export async function getWellKnown(baseUrl: string): Promise<{ 'org.matrix.msc2965.authentication'? :{ issuer: string, account?: string }, 'm.homeserver'?: { base_url?: string } }> { + const url = new URL(baseUrl); + url.search = ''; + url.pathname = '/.well-known/matrix/client'; + const response = await fetch(url.href); + return response.json(); +} + +export async function getLoginFlows(baseUrl: string) { + const tempClient = createClientImpl({ baseUrl }); + + return tempClient.loginFlows() as Promise<{ + flows: [ + { type: string } + ], + }>; +} + export async function loginWithPassword( localStorage: Storage, homeserver: string, diff --git a/src/components/App.svelte b/src/components/App.svelte index 3e6e4de..859cc43 100644 --- a/src/components/App.svelte +++ b/src/components/App.svelte @@ -33,12 +33,15 @@ limitations under the License. import { errorWrapper, setMenuPositions, sortEntries, workspaceNameValidator } from "../utils"; import { ToastContainer, FlatToast } from "svelte-toasts"; import Textfield from "@smui/textfield"; - import type { IFolderEntry } from "matrix-files-sdk"; + import type { IFolderEntry, TreeSpaceEntry } from "matrix-files-sdk"; import Register from "./auth/Register.svelte"; import IconButton from "@smui/icon-button"; import dayjs from "dayjs"; import 'dayjs/locale/en'; import localizedFormat from 'dayjs/plugin/localizedFormat'; + import { getLogger } from "log4js"; + + const log = getLogger('App'); dayjs.extend(localizedFormat); dayjs.locale('en'); @@ -83,10 +86,6 @@ limitations under the License. } } - router('/', async () => { - await redirectIfAuthed(() => router.redirect('/signin')); - }); - type ThenRoute = (ctx: PageJS.Context) => string; function requiresAuth(then: string | ThenRoute, and?: PageJS.Callback): PageJS.Callback { return (realCtx: PageJS.Context, realNext: () => void) => { @@ -123,9 +122,19 @@ limitations under the License. let workspaces: IFolderEntry[] = []; async function loadWorkspaces() { + log.info('loadWorkspaces()'); workspaces = sortEntries(await clientManager.files.getChildren()) as IFolderEntry[]; + workspaces.forEach(x => log.info((x as TreeSpaceEntry).treespace.room.getMyMembership())); } + router ('*', ({ path, querystring, hash }, next) => { + log.info(`GET ${path}${hash ? `#${hash}` : ''}${querystring ? `?${querystring}`: ''}`); + next(); + }); + + router('/', async () => { + await redirectIfAuthed(() => router.redirect('/signin')); + }); router('/signin', async (_ctx, next) => { await redirectIfAuthed(() => next()); }, () => page = Login); diff --git a/src/components/Settings.svelte b/src/components/Settings.svelte index b6740f1..3ab1628 100644 --- a/src/components/Settings.svelte +++ b/src/components/Settings.svelte @@ -27,6 +27,7 @@ limitations under the License. import type { DeviceTrustLevel } from "matrix-js-sdk/lib/crypto/CrossSigning"; import { Icon } from "@smui/button"; import type { IKeyBackupInfo } from 'matrix-js-sdk/lib/crypto/keybackup'; + import { getWellKnown } from '../auth'; export let clientManager: ClientManager; @@ -40,6 +41,7 @@ limitations under the License. let keyBackupInfo: IKeyBackupInfo | undefined; let devices: IMyDevice[] = []; let deviceTrust: Record = {}; + let manageAccountLink: string | undefined; async function update() { isCrossSigningReady = await client().isCrossSigningReady(); @@ -54,6 +56,7 @@ limitations under the License. return map; }, {} as Record); } + manageAccountLink = (await getWellKnown(client().baseUrl))['org.matrix.msc2965.authentication']?.account; } onMount(update); @@ -65,6 +68,11 @@ limitations under the License.

MatrixClient.isCrossSigningReady: {isCrossSigningReady === undefined ? 'checking' : isCrossSigningReady}

MatrixClient.isSecretStorageReady: {isSecretStorageReady === undefined ? 'checking' : isSecretStorageReady}

+{#if manageAccountLink } +

+ Manage account +

+{/if}

Sessions