Native OIDC login

This commit is contained in:
Quentin Gliech 2022-03-03 09:25:58 +01:00 committed by Quentin Gliech
parent 6ebb058e59
commit 9606de2e0f
No known key found for this signature in database
GPG Key ID: 22D62B84552719FC
16 changed files with 820 additions and 18 deletions

View File

@ -38,6 +38,8 @@ export class RootViewModel extends ViewModel {
this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("oidc-callback").subscribe(() => this._applyNavigation()));
this.track(this.navigation.observe("oidc-error").subscribe(() => this._applyNavigation()));
this._applyNavigation(true); this._applyNavigation(true);
} }
@ -46,6 +48,8 @@ export class RootViewModel extends ViewModel {
const logoutSessionId = this.navigation.path.get("logout")?.value; const logoutSessionId = this.navigation.path.get("logout")?.value;
const sessionId = this.navigation.path.get("session")?.value; const sessionId = this.navigation.path.get("session")?.value;
const loginToken = this.navigation.path.get("sso")?.value; const loginToken = this.navigation.path.get("sso")?.value;
const oidcCallback = this.navigation.path.get("oidc-callback")?.value;
const oidcError = this.navigation.path.get("oidc-error")?.value;
if (isLogin) { if (isLogin) {
if (this.activeSection !== "login") { if (this.activeSection !== "login") {
this._showLogin(); this._showLogin();
@ -77,7 +81,20 @@ export class RootViewModel extends ViewModel {
} else if (loginToken) { } else if (loginToken) {
this.urlCreator.normalizeUrl(); this.urlCreator.normalizeUrl();
if (this.activeSection !== "login") { if (this.activeSection !== "login") {
this._showLogin(loginToken); this._showLogin({loginToken});
}
} else if (oidcError) {
this._setSection(() => this._error = new Error(`OIDC error: ${oidcError[1]}`));
} else if (oidcCallback) {
this._setSection(() => this._error = new Error(`OIDC callback: state=${oidcCallback[0]}, code=${oidcCallback[1]}`));
this.urlCreator.normalizeUrl();
if (this.activeSection !== "login") {
this._showLogin({
oidc: {
state: oidcCallback[0],
code: oidcCallback[1],
}
});
} }
} }
else { else {
@ -109,7 +126,7 @@ export class RootViewModel extends ViewModel {
} }
} }
_showLogin(loginToken) { _showLogin({loginToken, oidc} = {}) {
this._setSection(() => { this._setSection(() => {
this._loginViewModel = new LoginViewModel(this.childOptions({ this._loginViewModel = new LoginViewModel(this.childOptions({
defaultHomeserver: this.platform.config["defaultHomeServer"], defaultHomeserver: this.platform.config["defaultHomeServer"],
@ -125,7 +142,8 @@ export class RootViewModel extends ViewModel {
this._pendingClient = client; this._pendingClient = client;
this.navigation.push("session", client.sessionId); this.navigation.push("session", client.sessionId);
}, },
loginToken loginToken,
oidc,
})); }));
}); });
} }

View File

@ -0,0 +1,84 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {OidcApi} from "../../matrix/net/OidcApi";
import {ViewModel} from "../ViewModel";
import {OIDCLoginMethod} from "../../matrix/login/OIDCLoginMethod";
import {LoginFailure} from "../../matrix/Client";
export class CompleteOIDCLoginViewModel extends ViewModel {
constructor(options) {
super(options);
const {
state,
code,
attemptLogin,
} = options;
this._request = options.platform.request;
this._encoding = options.platform.encoding;
this._state = state;
this._code = code;
this._attemptLogin = attemptLogin;
this._errorMessage = "";
this.performOIDCLoginCompletion();
}
get errorMessage() { return this._errorMessage; }
_showError(message) {
this._errorMessage = message;
this.emitChange("errorMessage");
}
async performOIDCLoginCompletion() {
if (!this._state || !this._code) {
return;
}
const code = this._code;
// TODO: cleanup settings storage
const [startedAt, nonce, codeVerifier, homeserver, issuer] = await Promise.all([
this.platform.settingsStorage.getInt(`oidc_${this._state}_started_at`),
this.platform.settingsStorage.getString(`oidc_${this._state}_nonce`),
this.platform.settingsStorage.getString(`oidc_${this._state}_code_verifier`),
this.platform.settingsStorage.getString(`oidc_${this._state}_homeserver`),
this.platform.settingsStorage.getString(`oidc_${this._state}_issuer`),
]);
const oidcApi = new OidcApi({
issuer,
clientId: "hydrogen-web",
request: this._request,
encoding: this._encoding,
});
const method = new OIDCLoginMethod({oidcApi, nonce, codeVerifier, code, homeserver, startedAt});
const status = await this._attemptLogin(method);
let error = "";
switch (status) {
case LoginFailure.Credentials:
error = this.i18n`Your login token is invalid.`;
break;
case LoginFailure.Connection:
error = this.i18n`Can't connect to ${homeserver}.`;
break;
case LoginFailure.Unknown:
error = this.i18n`Something went wrong while checking your login token.`;
break;
}
if (error) {
this._showError(error);
}
}
}

View File

@ -15,19 +15,24 @@ limitations under the License.
*/ */
import {Client} from "../../matrix/Client.js"; import {Client} from "../../matrix/Client.js";
import {OidcApi} from "../../matrix/net/OidcApi.js";
import {Options as BaseOptions, ViewModel} from "../ViewModel"; import {Options as BaseOptions, ViewModel} from "../ViewModel";
import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js"; import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js";
import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; import {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js";
import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js";
import {StartOIDCLoginViewModel} from "./StartOIDCLoginViewModel.js";
import {CompleteOIDCLoginViewModel} from "./CompleteOIDCLoginViewModel.js";
import {LoadStatus} from "../../matrix/Client.js"; import {LoadStatus} from "../../matrix/Client.js";
import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; import {SessionLoadViewModel} from "../SessionLoadViewModel.js";
import {SegmentType} from "../navigation/index"; import {SegmentType} from "../navigation/index";
import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login"; import type {PasswordLoginMethod, SSOLoginHelper, TokenLoginMethod, ILoginMethod} from "../../matrix/login";
import { OIDCLoginMethod } from "../../matrix/login/OIDCLoginMethod.js";
type Options = { type Options = {
defaultHomeserver: string; defaultHomeserver: string;
ready: ReadyFn; ready: ReadyFn;
oidc?: { state: string, code: string };
loginToken?: string; loginToken?: string;
} & BaseOptions; } & BaseOptions;
@ -35,10 +40,13 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
private _ready: ReadyFn; private _ready: ReadyFn;
private _loginToken?: string; private _loginToken?: string;
private _client: Client; private _client: Client;
private _oidc?: { state: string, code: string };
private _loginOptions?: LoginOptions; private _loginOptions?: LoginOptions;
private _passwordLoginViewModel?: PasswordLoginViewModel; private _passwordLoginViewModel?: PasswordLoginViewModel;
private _startSSOLoginViewModel?: StartSSOLoginViewModel; private _startSSOLoginViewModel?: StartSSOLoginViewModel;
private _completeSSOLoginViewModel?: CompleteSSOLoginViewModel; private _completeSSOLoginViewModel?: CompleteSSOLoginViewModel;
private _startOIDCLoginViewModel?: StartOIDCLoginViewModel;
private _completeOIDCLoginViewModel?: CompleteOIDCLoginViewModel;
private _loadViewModel?: SessionLoadViewModel; private _loadViewModel?: SessionLoadViewModel;
private _loadViewModelSubscription?: () => void; private _loadViewModelSubscription?: () => void;
private _homeserver: string; private _homeserver: string;
@ -52,9 +60,10 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
constructor(options: Readonly<Options>) { constructor(options: Readonly<Options>) {
super(options); super(options);
const {ready, defaultHomeserver, loginToken} = options; const {ready, defaultHomeserver, loginToken, oidc} = options;
this._ready = ready; this._ready = ready;
this._loginToken = loginToken; this._loginToken = loginToken;
this._oidc = oidc;
this._client = new Client(this.platform); this._client = new Client(this.platform);
this._homeserver = defaultHomeserver; this._homeserver = defaultHomeserver;
this._initViewModels(); this._initViewModels();
@ -72,6 +81,15 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
return this._completeSSOLoginViewModel; return this._completeSSOLoginViewModel;
} }
get startOIDCLoginViewModel(): StartOIDCLoginViewModel {
return this._startOIDCLoginViewModel;
}
get completeOIDCLoginViewModel(): CompleteOIDCLoginViewModel {
return this._completeOIDCLoginViewModel;
}
get homeserver(): string { get homeserver(): string {
return this._homeserver; return this._homeserver;
} }
@ -116,6 +134,18 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
}))); })));
this.emitChange("completeSSOLoginViewModel"); this.emitChange("completeSSOLoginViewModel");
} }
else if (this._oidc) {
this._hideHomeserver = true;
this._completeOIDCLoginViewModel = this.track(new CompleteOIDCLoginViewModel(
this.childOptions(
{
client: this._client,
attemptLogin: (loginMethod: OIDCLoginMethod) => this.attemptLogin(loginMethod),
state: this._oidc.state,
code: this._oidc.code,
})));
this.emitChange("completeOIDCLoginViewModel");
}
else { else {
void this.queryHomeserver(); void this.queryHomeserver();
} }
@ -137,6 +167,14 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
this.emitChange("startSSOLoginViewModel"); this.emitChange("startSSOLoginViewModel");
} }
private async _showOIDCLogin(): Promise<void> {
this._startOIDCLoginViewModel = this.track(
new StartOIDCLoginViewModel(this.childOptions({loginOptions: this._loginOptions}))
);
await this._startOIDCLoginViewModel.start();
this.emitChange("startOIDCLoginViewModel");
}
private _showError(message: string): void { private _showError(message: string): void {
this._errorMessage = message; this._errorMessage = message;
this.emitChange("errorMessage"); this.emitChange("errorMessage");
@ -263,7 +301,8 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
if (this._loginOptions) { if (this._loginOptions) {
if (this._loginOptions.sso) { this._showSSOLogin(); } if (this._loginOptions.sso) { this._showSSOLogin(); }
if (this._loginOptions.password) { this._showPasswordLogin(); } if (this._loginOptions.password) { this._showPasswordLogin(); }
if (!this._loginOptions.sso && !this._loginOptions.password) { if (this._loginOptions.oidc) { await this._showOIDCLogin(); }
if (!this._loginOptions.sso && !this._loginOptions.password && !this._loginOptions.oidc) {
this._showError("This homeserver supports neither SSO nor password based login flows"); this._showError("This homeserver supports neither SSO nor password based login flows");
} }
} }
@ -289,5 +328,6 @@ type LoginOptions = {
homeserver: string; homeserver: string;
password?: (username: string, password: string) => PasswordLoginMethod; password?: (username: string, password: string) => PasswordLoginMethod;
sso?: SSOLoginHelper; sso?: SSOLoginHelper;
oidc?: { issuer: string };
token?: (loginToken: string) => TokenLoginMethod; token?: (loginToken: string) => TokenLoginMethod;
}; };

View File

@ -0,0 +1,55 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {OidcApi} from "../../matrix/net/OidcApi";
import {ViewModel} from "../ViewModel";
export class StartOIDCLoginViewModel extends ViewModel {
constructor(options) {
super(options);
this._isBusy = true;
this._authorizationEndpoint = null;
this._api = new OidcApi({
clientId: "hydrogen-web",
issuer: options.loginOptions.oidc.issuer,
request: this.platform.request,
encoding: this.platform.encoding,
});
this._homeserver = options.loginOptions.homeserver;
}
get isBusy() { return this._isBusy; }
get authorizationEndpoint() { return this._authorizationEndpoint; }
async start() {
const p = this._api.generateParams("openid");
await Promise.all([
this.platform.settingsStorage.setInt(`oidc_${p.state}_started_at`, Date.now()),
this.platform.settingsStorage.setString(`oidc_${p.state}_nonce`, p.nonce),
this.platform.settingsStorage.setString(`oidc_${p.state}_code_verifier`, p.codeVerifier),
this.platform.settingsStorage.setString(`oidc_${p.state}_homeserver`, this._homeserver),
this.platform.settingsStorage.setString(`oidc_${p.state}_issuer`, this._api.issuer),
]);
this._authorizationEndpoint = await this._api.authorizationEndpoint(p);
this._isBusy = false;
}
setBusy(status) {
this._isBusy = status;
this.emitChange("isBusy");
}
}

View File

@ -33,6 +33,8 @@ export type SegmentType = {
"details": true; "details": true;
"members": true; "members": true;
"member": string; "member": string;
"oidc-callback": (string | null)[];
"oidc-error": (string | null)[];
}; };
export function createNavigation(): Navigation<SegmentType> { export function createNavigation(): Navigation<SegmentType> {
@ -48,7 +50,7 @@ function allowsChild(parent: Segment<SegmentType> | undefined, child: Segment<Se
switch (parent?.type) { switch (parent?.type) {
case undefined: case undefined:
// allowed root segments // allowed root segments
return type === "login" || type === "session" || type === "sso" || type === "logout"; return type === "login" || type === "session" || type === "sso" || type === "logout" || type === "oidc-callback" || type === "oidc-error";
case "session": case "session":
return type === "room" || type === "rooms" || type === "settings" || type === "create-room"; return type === "room" || type === "rooms" || type === "settings" || type === "create-room";
case "rooms": case "rooms":
@ -57,7 +59,7 @@ function allowsChild(parent: Segment<SegmentType> | undefined, child: Segment<Se
case "room": case "room":
return type === "lightbox" || type === "right-panel"; return type === "lightbox" || type === "right-panel";
case "right-panel": case "right-panel":
return type === "details"|| type === "members" || type === "member"; return type === "details" || type === "members" || type === "member";
default: default:
return false; return false;
} }
@ -124,10 +126,34 @@ export function addPanelIfNeeded<T extends SegmentType>(navigation: Navigation<T
} }
export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>, defaultSessionId?: string): Segment<SegmentType>[] { export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>, defaultSessionId?: string): Segment<SegmentType>[] {
const segments: Segment<SegmentType>[] = [];
// Special case for OIDC callback
if (urlPath.includes("state")) {
const params = new URLSearchParams(urlPath);
if (params.has("state")) {
// This is a proper OIDC callback
if (params.has("code")) {
segments.push(new Segment("oidc-callback", [
params.get("state"),
params.get("code"),
]));
return segments;
} else if (params.has("error")) {
segments.push(new Segment("oidc-error", [
params.get("state"),
params.get("error"),
params.get("error_description"),
params.get("error_uri"),
]));
return segments;
}
}
}
// substring(1) to take of initial / // substring(1) to take of initial /
const parts = urlPath.substring(1).split("/"); const parts = urlPath.substring(1).split("/");
const iterator = parts[Symbol.iterator](); const iterator = parts[Symbol.iterator]();
const segments: Segment<SegmentType>[] = [];
let next; let next;
while (!(next = iterator.next()).done) { while (!(next = iterator.next()).done) {
const type = next.value; const type = next.value;
@ -210,6 +236,8 @@ export function stringifyPath(path: Path<SegmentType>): string {
break; break;
case "right-panel": case "right-panel":
case "sso": case "sso":
case "oidc-callback":
case "oidc-error":
// Do not put these segments in URL // Do not put these segments in URL
continue; continue;
default: default:
@ -485,6 +513,23 @@ export function tests() {
assert.equal(newPath?.segments[1].type, "room"); assert.equal(newPath?.segments[1].type, "room");
assert.equal(newPath?.segments[1].value, "b"); assert.equal(newPath?.segments[1].value, "b");
}, },
"Parse OIDC callback": assert => {
const segments = parseUrlPath("state=tc9CnLU7&code=cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx");
assert.equal(segments.length, 1);
assert.equal(segments[0].type, "oidc-callback");
assert.deepEqual(segments[0].value, ["tc9CnLU7", "cnmUnwIYtY7V8RrWUyhJa4yvX72jJ5Yx"]);
},
"Parse OIDC error": assert => {
const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request");
assert.equal(segments.length, 1);
assert.equal(segments[0].type, "oidc-error");
assert.deepEqual(segments[0].value, ["tc9CnLU7", "invalid_request", null, null]);
},
"Parse OIDC error with description": assert => {
const segments = parseUrlPath("state=tc9CnLU7&error=invalid_request&error_description=Unsupported%20response_type%20value");
assert.equal(segments.length, 1);
assert.equal(segments[0].type, "oidc-error");
assert.deepEqual(segments[0].value, ["tc9CnLU7", "invalid_request", "Unsupported response_type value", null]);
},
} }
} }

View File

@ -20,6 +20,8 @@ import {lookupHomeserver} from "./well-known.js";
import {AbortableOperation} from "../utils/AbortableOperation"; import {AbortableOperation} from "../utils/AbortableOperation";
import {ObservableValue} from "../observable/ObservableValue"; import {ObservableValue} from "../observable/ObservableValue";
import {HomeServerApi} from "./net/HomeServerApi"; import {HomeServerApi} from "./net/HomeServerApi";
import {OidcApi} from "./net/OidcApi";
import {TokenRefresher} from "./net/TokenRefresher";
import {Reconnector, ConnectionStatus} from "./net/Reconnector"; import {Reconnector, ConnectionStatus} from "./net/Reconnector";
import {ExponentialRetryDelay} from "./net/ExponentialRetryDelay"; import {ExponentialRetryDelay} from "./net/ExponentialRetryDelay";
import {MediaRepository} from "./net/MediaRepository"; import {MediaRepository} from "./net/MediaRepository";
@ -123,11 +125,29 @@ export class Client {
return result; return result;
} }
queryLogin(homeserver) { queryLogin(initialHomeserver) {
return new AbortableOperation(async setAbortable => { return new AbortableOperation(async setAbortable => {
homeserver = await lookupHomeserver(homeserver, (url, options) => { const { homeserver, issuer } = await lookupHomeserver(initialHomeserver, (url, options) => {
return setAbortable(this._platform.request(url, options)); return setAbortable(this._platform.request(url, options));
}); });
if (issuer) {
try {
const oidcApi = new OidcApi({
issuer,
clientId: "hydrogen-web",
request: this._platform.request,
encoding: this._platform.encoding,
});
await oidcApi.validate();
return {
homeserver,
oidc: { issuer },
};
} catch (e) {
console.log(e);
}
}
const hsApi = new HomeServerApi({homeserver, request: this._platform.request}); const hsApi = new HomeServerApi({homeserver, request: this._platform.request});
const response = await setAbortable(hsApi.getLoginFlows()).response(); const response = await setAbortable(hsApi.getLoginFlows()).response();
return this._parseLoginOptions(response, homeserver); return this._parseLoginOptions(response, homeserver);
@ -172,6 +192,19 @@ export class Client {
accessToken: loginData.access_token, accessToken: loginData.access_token,
lastUsed: clock.now() lastUsed: clock.now()
}; };
if (loginData.refresh_token) {
sessionInfo.refreshToken = loginData.refresh_token;
}
if (loginData.expires_in) {
sessionInfo.accessTokenExpiresAt = clock.now() + loginData.expires_in * 1000;
}
if (loginData.oidc_issuer) {
sessionInfo.oidcIssuer = loginData.oidc_issuer;
}
log.set("id", sessionId); log.set("id", sessionId);
} catch (err) { } catch (err) {
this._error = err; this._error = err;
@ -225,9 +258,41 @@ export class Client {
retryDelay: new ExponentialRetryDelay(clock.createTimeout), retryDelay: new ExponentialRetryDelay(clock.createTimeout),
createMeasure: clock.createMeasure createMeasure: clock.createMeasure
}); });
let accessToken;
if (sessionInfo.oidcIssuer) {
const oidcApi = new OidcApi({
issuer: sessionInfo.oidcIssuer,
clientId: "hydrogen-web",
request: this._platform.request,
encoding: this._platform.encoding,
});
// TODO: stop/pause the refresher?
const tokenRefresher = new TokenRefresher({
oidcApi,
clock: this._platform.clock,
accessToken: sessionInfo.accessToken,
accessTokenExpiresAt: sessionInfo.accessTokenExpiresAt,
refreshToken: sessionInfo.refreshToken,
anticipation: 30 * 1000,
});
tokenRefresher.token.subscribe(t => {
this._platform.sessionInfoStorage.updateToken(sessionInfo.id, t.accessToken, t.accessTokenExpiresAt, t.refreshToken);
});
await tokenRefresher.start();
accessToken = tokenRefresher.accessToken;
} else {
accessToken = new ObservableValue(sessionInfo.accessToken);
}
const hsApi = new HomeServerApi({ const hsApi = new HomeServerApi({
homeserver: sessionInfo.homeServer, homeserver: sessionInfo.homeServer,
accessToken: sessionInfo.accessToken, accessToken,
request: this._platform.request, request: this._platform.request,
reconnector: this._reconnector, reconnector: this._reconnector,
}); });

View File

@ -0,0 +1,67 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {ILogItem} from "../../logging/types";
import {ILoginMethod} from "./LoginMethod";
import {HomeServerApi} from "../net/HomeServerApi.js";
import {OidcApi} from "../net/OidcApi";
export class OIDCLoginMethod implements ILoginMethod {
private readonly _code: string;
private readonly _codeVerifier: string;
private readonly _nonce: string;
private readonly _oidcApi: OidcApi;
public readonly homeserver: string;
constructor({
nonce,
codeVerifier,
code,
homeserver,
oidcApi,
}: {
nonce: string,
code: string,
codeVerifier: string,
homeserver: string,
oidcApi: OidcApi,
}) {
this._oidcApi = oidcApi;
this._code = code;
this._codeVerifier = codeVerifier;
this._nonce = nonce;
this.homeserver = homeserver;
}
async login(hsApi: HomeServerApi, _deviceName: string, log: ILogItem): Promise<Record<string, any>> {
const { access_token, refresh_token, expires_in } = await this._oidcApi.completeAuthorizationCodeGrant({
code: this._code,
codeVerifier: this._codeVerifier,
});
// TODO: validate the id_token and the nonce claim
// Do a "whoami" request to find out the user_id and device_id
const { user_id, device_id } = await hsApi.whoami({
log,
accessTokenOverride: access_token,
}).response();
const oidc_issuer = this._oidcApi.issuer;
return { oidc_issuer, access_token, refresh_token, expires_in, user_id, device_id };
}
}

View File

@ -31,7 +31,7 @@ const DEHYDRATION_PREFIX = "/_matrix/client/unstable/org.matrix.msc2697.v2";
type Options = { type Options = {
homeserver: string; homeserver: string;
accessToken: string; accessToken: BaseObservableValue<string>;
request: RequestFunction; request: RequestFunction;
reconnector: Reconnector; reconnector: Reconnector;
}; };
@ -42,11 +42,12 @@ type BaseRequestOptions = {
uploadProgress?: (loadedBytes: number) => void; uploadProgress?: (loadedBytes: number) => void;
timeout?: number; timeout?: number;
prefix?: string; prefix?: string;
accessTokenOverride?: string;
}; };
export class HomeServerApi { export class HomeServerApi {
private readonly _homeserver: string; private readonly _homeserver: string;
private readonly _accessToken: string; private readonly _accessToken: BaseObservableValue<string>;
private readonly _requestFn: RequestFunction; private readonly _requestFn: RequestFunction;
private readonly _reconnector: Reconnector; private readonly _reconnector: Reconnector;
@ -63,11 +64,19 @@ export class HomeServerApi {
return this._homeserver + prefix + csPath; return this._homeserver + prefix + csPath;
} }
private _baseRequest(method: RequestMethod, url: string, queryParams?: Record<string, any>, body?: Record<string, any>, options?: BaseRequestOptions, accessToken?: string): IHomeServerRequest { private _baseRequest(method: RequestMethod, url: string, queryParams?: Record<string, any>, body?: Record<string, any>, options?: BaseRequestOptions, accessTokenSource?: BaseObservableValue<string>): IHomeServerRequest {
const queryString = encodeQueryParams(queryParams); const queryString = encodeQueryParams(queryParams);
url = `${url}?${queryString}`; url = `${url}?${queryString}`;
let encodedBody: EncodedBody["body"]; let encodedBody: EncodedBody["body"];
const headers: Map<string, string | number> = new Map(); const headers: Map<string, string | number> = new Map();
let accessToken: string | null = null;
if (options?.accessTokenOverride) {
accessToken = options.accessTokenOverride;
} else if (accessTokenSource) {
accessToken = accessTokenSource.get();
}
if (accessToken) { if (accessToken) {
headers.set("Authorization", `Bearer ${accessToken}`); headers.set("Authorization", `Bearer ${accessToken}`);
} }
@ -279,6 +288,10 @@ export class HomeServerApi {
return this._post(`/logout`, {}, {}, options); return this._post(`/logout`, {}, {}, options);
} }
whoami(options?: BaseRequestOptions): IHomeServerRequest {
return this._get(`/account/whoami`, undefined, undefined, options);
}
getDehydratedDevice(options: BaseRequestOptions = {}): IHomeServerRequest { getDehydratedDevice(options: BaseRequestOptions = {}): IHomeServerRequest {
options.prefix = DEHYDRATION_PREFIX; options.prefix = DEHYDRATION_PREFIX;
return this._get(`/dehydrated_device`, undefined, undefined, options); return this._get(`/dehydrated_device`, undefined, undefined, options);
@ -308,6 +321,7 @@ export class HomeServerApi {
} }
import {Request as MockRequest} from "../../mocks/Request.js"; import {Request as MockRequest} from "../../mocks/Request.js";
import {BaseObservableValue} from "../../observable/ObservableValue";
export function tests() { export function tests() {
return { return {

221
src/matrix/net/OidcApi.ts Normal file
View File

@ -0,0 +1,221 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the 'License');
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an 'AS IS' BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const WELL_KNOWN = ".well-known/openid-configuration";
const RANDOM_CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const randomChar = () => RANDOM_CHARSET.charAt(Math.floor(Math.random() * 1e10) % RANDOM_CHARSET.length);
const randomString = (length: number) =>
Array.from({ length }, randomChar).join("");
type BearerToken = {
token_type: "Bearer",
access_token: string,
refresh_token?: string,
expires_in?: number,
}
const isValidBearerToken = (t: any): t is BearerToken =>
typeof t == "object" &&
t["token_type"] === "Bearer" &&
typeof t["access_token"] === "string" &&
(!("refresh_token" in t) || typeof t["refresh_token"] === "string") &&
(!("expires_in" in t) || typeof t["expires_in"] === "number");
type AuthorizationParams = {
state: string,
scope: string,
nonce?: string,
codeVerifier?: string,
};
function assert(condition: any, message: string): asserts condition {
if (!condition) {
throw new Error(`Assertion failed: ${message}`);
}
};
export class OidcApi {
_issuer: string;
_clientId: string;
_requestFn: any;
_base64: any;
_metadataPromise: Promise<any>;
constructor({ issuer, clientId, request, encoding }) {
this._issuer = issuer;
this._clientId = clientId;
this._requestFn = request;
this._base64 = encoding.base64;
}
get metadataUrl() {
return new URL(WELL_KNOWN, this._issuer).toString();
}
get issuer() {
return this._issuer;
}
get redirectUri() {
return window.location.origin;
}
metadata() {
if (!this._metadataPromise) {
this._metadataPromise = (async () => {
const headers = new Map();
headers.set("Accept", "application/json");
const req = this._requestFn(this.metadataUrl, {
method: "GET",
headers,
format: "json",
});
const res = await req.response();
if (res.status >= 400) {
throw new Error("failed to request metadata");
}
return res.body;
})();
}
return this._metadataPromise;
}
async validate() {
const m = await this.metadata();
assert(typeof m.authorization_endpoint === "string", "Has an authorization endpoint");
assert(typeof m.token_endpoint === "string", "Has a token endpoint");
assert(Array.isArray(m.response_types_supported) && m.response_types_supported.includes("code"), "Supports the code response type");
assert(Array.isArray(m.response_modes_supported) && m.response_modes_supported.includes("fragment"), "Supports the fragment response mode");
assert(Array.isArray(m.grant_types_supported) && m.grant_types_supported.includes("authorization_code"), "Supports the authorization_code grant type");
assert(Array.isArray(m.code_challenge_methods_supported) && m.code_challenge_methods_supported.includes("S256"), "Supports the authorization_code grant type");
}
async _generateCodeChallenge(
codeVerifier: string
): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await window.crypto.subtle.digest("SHA-256", data);
const base64Digest = this._base64.encode(digest);
return base64Digest.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
async authorizationEndpoint({
state,
scope,
nonce,
codeVerifier,
}: AuthorizationParams) {
const metadata = await this.metadata();
const url = new URL(metadata["authorization_endpoint"]);
url.searchParams.append("response_mode", "fragment");
url.searchParams.append("response_type", "code");
url.searchParams.append("redirect_uri", this.redirectUri);
url.searchParams.append("client_id", this._clientId);
url.searchParams.append("state", state);
url.searchParams.append("scope", scope);
if (nonce) {
url.searchParams.append("nonce", nonce);
}
if (codeVerifier) {
url.searchParams.append("code_challenge_method", "S256");
url.searchParams.append("code_challenge", await this._generateCodeChallenge(codeVerifier));
}
return url.toString();
}
async tokenEndpoint() {
const metadata = await this.metadata();
return metadata["token_endpoint"];
}
generateParams(scope: string): AuthorizationParams {
return {
scope,
state: randomString(8),
nonce: randomString(8),
codeVerifier: randomString(32),
};
}
async completeAuthorizationCodeGrant({
codeVerifier,
code,
}: { codeVerifier: string, code: string }): Promise<BearerToken> {
const params = new URLSearchParams();
params.append("grant_type", "authorization_code");
params.append("client_id", this._clientId);
params.append("code_verifier", codeVerifier);
params.append("redirect_uri", this.redirectUri);
params.append("code", code);
const body = params.toString();
const headers = new Map();
headers.set("Content-Type", "application/x-www-form-urlencoded");
const req = this._requestFn(await this.tokenEndpoint(), {
method: "POST",
headers,
format: "json",
body,
});
const res = await req.response();
if (res.status >= 400) {
throw new Error("failed to exchange authorization code");
}
const token = res.body;
assert(isValidBearerToken(token), "Got back a valid bearer token");
return token;
}
async refreshToken({
refreshToken,
}: { refreshToken: string }): Promise<BearerToken> {
const params = new URLSearchParams();
params.append("grant_type", "refresh_token");
params.append("client_id", this._clientId);
params.append("refresh_token", refreshToken);
const body = params.toString();
const headers = new Map();
headers.set("Content-Type", "application/x-www-form-urlencoded");
const req = this._requestFn(await this.tokenEndpoint(), {
method: "POST",
headers,
format: "json",
body,
});
const res = await req.response();
if (res.status >= 400) {
throw new Error("failed to use refresh token");
}
const token = res.body;
assert(isValidBearerToken(token), "Got back a valid bearer token");
return token;
}
}

View File

@ -0,0 +1,125 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the 'License');
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an 'AS IS' BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue";
import type {Clock, Timeout} from "../../platform/web/dom/Clock";
import {OidcApi} from "./OidcApi";
type Token = {
accessToken: string,
accessTokenExpiresAt: number,
refreshToken: string,
};
export class TokenRefresher {
private _token: ObservableValue<Token>;
private _accessToken: BaseObservableValue<string>;
private _anticipation: number;
private _clock: Clock;
private _oidcApi: OidcApi;
private _timeout: Timeout
constructor({
oidcApi,
refreshToken,
accessToken,
accessTokenExpiresAt,
anticipation,
clock,
}: {
oidcApi: OidcApi,
refreshToken: string,
accessToken: string,
accessTokenExpiresAt: number,
anticipation: number,
clock: Clock,
}) {
this._token = new ObservableValue({
accessToken,
accessTokenExpiresAt,
refreshToken,
});
this._accessToken = this._token.map(t => t.accessToken);
this._anticipation = anticipation;
this._oidcApi = oidcApi;
this._clock = clock;
}
async start() {
if (this.needsRenewing) {
await this.renew();
}
this._renewingLoop();
}
stop() {
// TODO
}
get needsRenewing() {
const remaining = this._token.get().accessTokenExpiresAt - this._clock.now();
const anticipated = remaining - this._anticipation;
return anticipated < 0;
}
async _renewingLoop() {
while (true) {
const remaining =
this._token.get().accessTokenExpiresAt - this._clock.now();
const anticipated = remaining - this._anticipation;
if (anticipated > 0) {
this._timeout = this._clock.createTimeout(anticipated);
await this._timeout.elapsed();
}
await this.renew();
}
}
async renew() {
let refreshToken = this._token.get().refreshToken;
const response = await this._oidcApi
.refreshToken({
refreshToken,
});
if (typeof response.expires_in !== "number") {
throw new Error("Refreshed access token does not expire");
}
if (response.refresh_token) {
refreshToken = response.refresh_token;
}
this._token.set({
refreshToken,
accessToken: response.access_token,
accessTokenExpiresAt: this._clock.now() + response.expires_in * 1000,
});
}
get accessToken(): BaseObservableValue<string> {
return this._accessToken;
}
get token(): BaseObservableValue<Token> {
return this._token;
}
}

View File

@ -21,6 +21,9 @@ interface ISessionInfo {
homeserver: string; homeserver: string;
homeServer: string; // deprecate this over time homeServer: string; // deprecate this over time
accessToken: string; accessToken: string;
accessTokenExpiresAt?: number;
refreshToken?: string;
oidcIssuer?: string;
lastUsed: number; lastUsed: number;
} }
@ -28,6 +31,7 @@ interface ISessionInfo {
interface ISessionInfoStorage { interface ISessionInfoStorage {
getAll(): Promise<ISessionInfo[]>; getAll(): Promise<ISessionInfo[]>;
updateLastUsed(id: string, timestamp: number): Promise<void>; updateLastUsed(id: string, timestamp: number): Promise<void>;
updateToken(id: string, accessToken: string, accessTokenExpiresAt: number, refreshToken: string): Promise<void>;
get(id: string): Promise<ISessionInfo | undefined>; get(id: string): Promise<ISessionInfo | undefined>;
add(sessionInfo: ISessionInfo): Promise<void>; add(sessionInfo: ISessionInfo): Promise<void>;
delete(sessionId: string): Promise<void>; delete(sessionId: string): Promise<void>;
@ -62,6 +66,19 @@ export class SessionInfoStorage implements ISessionInfoStorage {
} }
} }
async updateToken(id: string, accessToken: string, accessTokenExpiresAt: number, refreshToken: string): Promise<void> {
const sessions = await this.getAll();
if (sessions) {
const session = sessions.find(session => session.id === id);
if (session) {
session.accessToken = accessToken;
session.accessTokenExpiresAt = accessTokenExpiresAt;
session.refreshToken = refreshToken;
localStorage.setItem(this._name, JSON.stringify(sessions));
}
}
}
async get(id: string): Promise<ISessionInfo | undefined> { async get(id: string): Promise<ISessionInfo | undefined> {
const sessions = await this.getAll(); const sessions = await this.getAll();
if (sessions) { if (sessions) {

View File

@ -41,6 +41,7 @@ async function getWellKnownResponse(homeserver, request) {
export async function lookupHomeserver(homeserver, request) { export async function lookupHomeserver(homeserver, request) {
homeserver = normalizeHomeserver(homeserver); homeserver = normalizeHomeserver(homeserver);
let issuer = null;
const wellKnownResponse = await getWellKnownResponse(homeserver, request); const wellKnownResponse = await getWellKnownResponse(homeserver, request);
if (wellKnownResponse && wellKnownResponse.status === 200) { if (wellKnownResponse && wellKnownResponse.status === 200) {
const {body} = wellKnownResponse; const {body} = wellKnownResponse;
@ -48,6 +49,11 @@ export async function lookupHomeserver(homeserver, request) {
if (typeof wellKnownHomeserver === "string") { if (typeof wellKnownHomeserver === "string") {
homeserver = normalizeHomeserver(wellKnownHomeserver); homeserver = normalizeHomeserver(wellKnownHomeserver);
} }
const wellKnownIssuer = body["m.authentication"]?.["issuer"];
if (typeof wellKnownIssuer === "string") {
issuer = wellKnownIssuer;
}
} }
return homeserver; return {homeserver, issuer};
} }

View File

@ -39,6 +39,10 @@ export abstract class BaseObservableValue<T> extends BaseObservable<(value: T) =
flatMap<C>(mapper: (value: T) => (BaseObservableValue<C> | undefined)): BaseObservableValue<C | undefined> { flatMap<C>(mapper: (value: T) => (BaseObservableValue<C> | undefined)): BaseObservableValue<C | undefined> {
return new FlatMapObservableValue<T, C>(this, mapper); return new FlatMapObservableValue<T, C>(this, mapper);
} }
map<C>(mapper: (value: T) => C): BaseObservableValue<C> {
return new MappedObservableValue<T, C>(this, mapper);
}
} }
interface IWaitHandle<T> { interface IWaitHandle<T> {
@ -174,6 +178,34 @@ export class FlatMapObservableValue<P, C> extends BaseObservableValue<C | undefi
} }
} }
export class MappedObservableValue<P, C> extends BaseObservableValue<C> {
private sourceSubscription?: SubscriptionHandle;
constructor(
private readonly source: BaseObservableValue<P>,
private readonly mapper: (value: P) => C
) {
super();
}
onUnsubscribeLast() {
super.onUnsubscribeLast();
this.sourceSubscription = this.sourceSubscription!();
}
onSubscribeFirst() {
super.onSubscribeFirst();
this.sourceSubscription = this.source.subscribe(() => {
this.emit(this.get());
});
}
get(): C {
const sourceValue = this.source.get();
return this.mapper(sourceValue);
}
}
export function tests() { export function tests() {
return { return {
"set emits an update": assert => { "set emits an update": assert => {

View File

@ -26,6 +26,7 @@ export interface IRequestOptions {
cache?: boolean; cache?: boolean;
method?: string; method?: string;
format?: string; format?: string;
accessTokenOverride?: string;
} }
export type RequestFunction = (url: string, options: IRequestOptions) => RequestResult; export type RequestFunction = (url: string, options: IRequestOptions) => RequestResult;

View File

@ -68,13 +68,13 @@ limitations under the License.
--size: 20px; --size: 20px;
} }
.StartSSOLoginView { .StartSSOLoginView, .StartOIDCLoginView {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0 0.4em 0; padding: 0 0.4em 0;
} }
.StartSSOLoginView_button { .StartSSOLoginView_button, .StartOIDCLoginView_button {
flex: 1; flex: 1;
margin-top: 12px; margin-top: 12px;
} }

View File

@ -57,6 +57,7 @@ export class LoginView extends TemplateView {
t.mapView(vm => vm.passwordLoginViewModel, vm => vm ? new PasswordLoginView(vm): null), t.mapView(vm => vm.passwordLoginViewModel, vm => vm ? new PasswordLoginView(vm): null),
t.if(vm => vm.passwordLoginViewModel && vm.startSSOLoginViewModel, t => t.p({className: "LoginView_separator"}, vm.i18n`or`)), t.if(vm => vm.passwordLoginViewModel && vm.startSSOLoginViewModel, t => t.p({className: "LoginView_separator"}, vm.i18n`or`)),
t.mapView(vm => vm.startSSOLoginViewModel, vm => vm ? new StartSSOLoginView(vm) : null), t.mapView(vm => vm.startSSOLoginViewModel, vm => vm ? new StartSSOLoginView(vm) : null),
t.mapView(vm => vm.startOIDCLoginViewModel, vm => vm ? new StartOIDCLoginView(vm) : null),
t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null),
// use t.mapView rather than t.if to create a new view when the view model changes too // use t.mapView rather than t.if to create a new view when the view model changes too
t.p(hydrogenGithubLink(t)) t.p(hydrogenGithubLink(t))
@ -76,3 +77,14 @@ class StartSSOLoginView extends TemplateView {
); );
} }
} }
class StartOIDCLoginView extends TemplateView {
render(t, vm) {
return t.div({ className: "StartOIDCLoginView" },
t.a({
className: "StartOIDCLoginView_button button-action secondary",
href: vm => (vm.isBusy ? "#" : vm.authorizationEndpoint),
}, vm.i18n`Log in via OIDC`)
);
}
}