1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-23 17:02:25 +03:00

OIDC: Log in (#3554)

* use oidc-client-ts during oidc discovery

* export new type for auth config

* deprecate generateAuthorizationUrl in favour of generateOidcAuthorizationUrl

* testing util for oidc configurations

* test generateOidcAuthorizationUrl

* lint

* test discovery

* dont pass whole client wellknown to oidc validation funcs

* add nonce

* use oidc-client-ts for oidc response

* validate user state and update tests

* use oidc-client-ts for code exchange

* use oidc-client-ts in completing auth grant

* use client userState for homeserver

* more comments
This commit is contained in:
Kerry
2023-07-11 14:20:19 +12:00
committed by GitHub
parent e68a1471c1
commit 5df4ebaada
5 changed files with 331 additions and 160 deletions

View File

@@ -14,15 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { OidcClient, WebStorageStateStore } from "oidc-client-ts";
import { Log, OidcClient, SigninResponse, SigninState, WebStorageStateStore } from "oidc-client-ts";
import { IDelegatedAuthConfig } from "../client";
import { Method } from "../http-api";
import { subtleCrypto, TextEncoder } from "../crypto/crypto";
import { logger } from "../logger";
import { randomString } from "../randomstring";
import { OidcError } from "./error";
import { validateIdToken, ValidatedIssuerConfig, ValidatedIssuerMetadata, UserState } from "./validate";
import {
validateIdToken,
ValidatedIssuerMetadata,
validateStoredUserState,
UserState,
BearerTokenResponse,
validateBearerTokenResponse,
} from "./validate";
// reexport for backwards compatibility
export type { BearerTokenResponse };
/**
* Authorization parameters which are used in the authentication request of an OIDC auth code flow.
@@ -152,37 +161,6 @@ export const generateOidcAuthorizationUrl = async ({
return request.url;
};
/**
* The expected response type from the token endpoint during authorization code flow
* Normalized to always use capitalized 'Bearer' for token_type
*
* See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4,
* https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
*/
export type BearerTokenResponse = {
token_type: "Bearer";
access_token: string;
scope: string;
refresh_token?: string;
expires_in?: number;
id_token?: string;
};
/**
* Expected response type from the token endpoint during authorization code flow
* as it comes over the wire.
* Should be normalized to use capital case 'Bearer' for token_type property
*
* See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4,
* https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
*/
type WireBearerTokenResponse = BearerTokenResponse & {
token_type: "Bearer" | "bearer";
};
const isResponseObject = (response: unknown): response is Record<string, unknown> =>
!!response && typeof response === "object";
/**
* Normalize token_type to use capital case to make consuming the token response easier
* token_type is case insensitive, and it is spec-compliant for OPs to return token_type: "bearer"
@@ -192,21 +170,18 @@ const isResponseObject = (response: unknown): response is Record<string, unknown
* @param response - validated token response
* @returns response with token_type set to 'Bearer'
*/
const normalizeBearerTokenResponseTokenType = (response: WireBearerTokenResponse): BearerTokenResponse => ({
...response,
token_type: "Bearer",
});
const isValidBearerTokenResponse = (response: unknown): response is WireBearerTokenResponse =>
isResponseObject(response) &&
typeof response["token_type"] === "string" &&
// token_type is case insensitive, some OPs return `token_type: "bearer"`
response["token_type"].toLowerCase() === "bearer" &&
typeof response["access_token"] === "string" &&
(!("refresh_token" in response) || typeof response["refresh_token"] === "string") &&
(!("expires_in" in response) || typeof response["expires_in"] === "number");
const normalizeBearerTokenResponseTokenType = (response: SigninResponse): BearerTokenResponse =>
({
id_token: response.id_token,
scope: response.scope,
expires_at: response.expires_at,
refresh_token: response.refresh_token,
access_token: response.access_token,
token_type: "Bearer",
} as BearerTokenResponse);
/**
* @experimental
* Attempt to exchange authorization code for bearer token.
*
* Takes the authorization code returned by the OpenID Provider via the authorization URL, and makes a
@@ -219,47 +194,71 @@ const isValidBearerTokenResponse = (response: unknown): response is WireBearerTo
*/
export const completeAuthorizationCodeGrant = async (
code: string,
{
clientId,
codeVerifier,
redirectUri,
delegatedAuthConfig,
nonce,
}: {
clientId: string;
codeVerifier: string;
redirectUri: string;
delegatedAuthConfig: IDelegatedAuthConfig & ValidatedIssuerConfig;
nonce: string;
},
): Promise<BearerTokenResponse> => {
const params = new URLSearchParams();
params.append("grant_type", "authorization_code");
params.append("client_id", clientId);
params.append("code_verifier", codeVerifier);
params.append("redirect_uri", redirectUri);
params.append("code", code);
const metadata = params.toString();
state: string,
): Promise<{
oidcClientSettings: IDelegatedAuthConfig & { clientId: string };
tokenResponse: BearerTokenResponse;
homeserverUrl: string;
identityServerUrl?: string;
}> => {
/**
* Element Web strips and changes the url on starting the app
* Use the code and state from query params to rebuild a url
* so that oidc-client can parse it
*/
const reconstructedUrl = new URL(window.location.origin);
reconstructedUrl.searchParams.append("code", code);
reconstructedUrl.searchParams.append("state", state);
const headers = { "Content-Type": "application/x-www-form-urlencoded" };
// set oidc-client to use our logger
Log.setLogger(logger);
try {
const response = new SigninResponse(reconstructedUrl.searchParams);
const response = await fetch(delegatedAuthConfig.tokenEndpoint, {
method: Method.Post,
headers,
body: metadata,
});
const stateStore = new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage });
if (response.status >= 400) {
// retrieve the state we put in storage at the start of oidc auth flow
const stateString = await stateStore.get(response.state!);
if (!stateString) {
throw new Error(OidcError.MissingOrInvalidStoredState);
}
// hydrate the sign in state and create a client
// the stored sign in state includes oidc configuration we set at the start of the oidc login flow
const signInState = SigninState.fromStorageString(stateString);
const client = new OidcClient({ ...signInState, stateStore });
// validate the code and state, and attempt to swap the code for tokens
const signinResponse = await client.processSigninResponse(reconstructedUrl.href);
// extra values we stored at the start of the login flow
// used to complete login in the client
const userState = signinResponse.userState;
validateStoredUserState(userState);
// throws when response is invalid
validateBearerTokenResponse(signinResponse);
// throws when token is invalid
validateIdToken(signinResponse.id_token, client.settings.authority, client.settings.client_id, userState.nonce);
const normalizedTokenResponse = normalizeBearerTokenResponseTokenType(signinResponse);
return {
oidcClientSettings: {
clientId: client.settings.client_id,
issuer: client.settings.authority,
},
tokenResponse: normalizedTokenResponse,
homeserverUrl: userState.homeserverUrl,
identityServerUrl: userState.identityServerUrl,
};
} catch (error) {
logger.error("Oidc login failed", error);
const errorType = (error as Error).message;
// rethrow errors that we recognise
if (Object.values(OidcError).includes(errorType as any)) {
throw error;
}
throw new Error(OidcError.CodeExchangeFailed);
}
const token = await response.json();
if (isValidBearerTokenResponse(token)) {
// throws when token is invalid
validateIdToken(token.id_token, delegatedAuthConfig.issuer, clientId, nonce);
return normalizeBearerTokenResponseTokenType(token);
}
throw new Error(OidcError.InvalidBearerTokenResponse);
};

View File

@@ -25,4 +25,5 @@ export enum OidcError {
CodeExchangeFailed = "Failed to exchange code for token",
InvalidBearerTokenResponse = "Invalid bearer token response",
InvalidIdToken = "Invalid ID token",
MissingOrInvalidStoredState = "State required to finish logging in is not found in storage.",
}

View File

@@ -15,7 +15,7 @@ limitations under the License.
*/
import jwtDecode from "jwt-decode";
import { OidcMetadata } from "oidc-client-ts";
import { OidcMetadata, SigninResponse } from "oidc-client-ts";
import { IDelegatedAuthConfig } from "../client";
import { logger } from "../logger";
@@ -62,14 +62,14 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
const requiredStringProperty = (wellKnown: Record<string, unknown>, key: string): boolean => {
if (!wellKnown[key] || !optionalStringProperty(wellKnown, key)) {
logger.error(`OIDC issuer configuration: ${key} is invalid`);
logger.error(`Missing or invalid property: ${key}`);
return false;
}
return true;
};
const optionalStringProperty = (wellKnown: Record<string, unknown>, key: string): boolean => {
if (!!wellKnown[key] && typeof wellKnown[key] !== "string") {
logger.error(`OIDC issuer configuration: ${key} is invalid`);
logger.error(`Invalid property: ${key}`);
return false;
}
return true;
@@ -77,7 +77,7 @@ const optionalStringProperty = (wellKnown: Record<string, unknown>, key: string)
const requiredArrayValue = (wellKnown: Record<string, unknown>, key: string, value: any): boolean => {
const array = wellKnown[key];
if (!array || !Array.isArray(array) || !array.includes(value)) {
logger.error(`OIDC issuer configuration: ${key} is invalid. ${value} is required.`);
logger.error(`Invalid property: ${key}. ${value} is required.`);
return false;
}
return true;
@@ -245,3 +245,64 @@ export type UserState = {
*/
nonce: string;
};
/**
* Validate stored user state exists and is valid
* @param userState - userState returned by oidcClient.processSigninResponse
* @throws when userState is invalid
*/
export function validateStoredUserState(userState: unknown): asserts userState is UserState {
if (!isRecord(userState)) {
logger.error("Stored user state not found");
throw new Error(OidcError.MissingOrInvalidStoredState);
}
const isInvalid = [
requiredStringProperty(userState, "homeserverUrl"),
requiredStringProperty(userState, "nonce"),
optionalStringProperty(userState, "identityServerUrl"),
].some((isValid) => !isValid);
if (isInvalid) {
throw new Error(OidcError.MissingOrInvalidStoredState);
}
}
/**
* The expected response type from the token endpoint during authorization code flow
* Normalized to always use capitalized 'Bearer' for token_type
*
* See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4,
* https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
*/
export type BearerTokenResponse = {
token_type: "Bearer";
access_token: string;
scope: string;
refresh_token?: string;
expires_in?: number;
// from oidc-client-ts
expires_at?: number;
id_token?: string;
};
/**
* Make required properties required in type
*/
type ValidSignInResponse = SigninResponse &
BearerTokenResponse & {
token_type: "Bearer" | "bearer";
};
const isValidBearerTokenResponse = (response: unknown): response is ValidSignInResponse =>
isRecord(response) &&
requiredStringProperty(response, "token_type") &&
// token_type is case insensitive, some OPs return `token_type: "bearer"`
(response["token_type"] as string).toLowerCase() === "bearer" &&
requiredStringProperty(response, "access_token") &&
requiredStringProperty(response, "refresh_token") &&
(!("expires_in" in response) || typeof response["expires_in"] === "number");
export function validateBearerTokenResponse(response: unknown): asserts response is ValidSignInResponse {
if (!isValidBearerTokenResponse(response)) {
throw new Error(OidcError.InvalidBearerTokenResponse);
}
}