From 9de4a057df6e546716cd9ab29be336976c8daa2b Mon Sep 17 00:00:00 2001 From: Kerry Date: Tue, 27 Jun 2023 11:46:53 +1200 Subject: [PATCH] OIDC: navigate to authorization endpoint (#3499) * utils for authorization step in OIDC code grant * tidy * completeAuthorizationCodeGrant util functions * response_mode=query * add scope to bearertoken type * add is_guest to whoami response type * doc comments Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * use shimmed TextEncoder * fetchMockJest -> fetchMock * comment * bearertokenresponse * test for lowercase bearer * handle lowercase token_type --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> --- spec/unit/oidc/authorize.spec.ts | 209 ++++++++++++++++++++++++++++++ src/client.ts | 1 + src/oidc/authorize.ts | 210 +++++++++++++++++++++++++++++++ src/oidc/error.ts | 2 + 4 files changed, 422 insertions(+) create mode 100644 spec/unit/oidc/authorize.spec.ts create mode 100644 src/oidc/authorize.ts diff --git a/spec/unit/oidc/authorize.spec.ts b/spec/unit/oidc/authorize.spec.ts new file mode 100644 index 00000000000..51f46aadf8d --- /dev/null +++ b/spec/unit/oidc/authorize.spec.ts @@ -0,0 +1,209 @@ +/* +Copyright 2023 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 fetchMock from "fetch-mock-jest"; + +import { Method } from "../../../src"; +import * as crypto from "../../../src/crypto/crypto"; +import { logger } from "../../../src/logger"; +import { + completeAuthorizationCodeGrant, + generateAuthorizationParams, + generateAuthorizationUrl, +} from "../../../src/oidc/authorize"; +import { OidcError } from "../../../src/oidc/error"; + +// save for resetting mocks +const realSubtleCrypto = crypto.subtleCrypto; + +describe("oidc authorization", () => { + const issuer = "https://auth.com/"; + const authorizationEndpoint = "https://auth.com/authorization"; + const tokenEndpoint = "https://auth.com/token"; + const delegatedAuthConfig = { + issuer, + registrationEndpoint: issuer + "registration", + authorizationEndpoint: issuer + "auth", + tokenEndpoint, + }; + const clientId = "xyz789"; + const baseUrl = "https://test.com"; + + beforeAll(() => { + jest.spyOn(logger, "warn"); + }); + + afterEach(() => { + // @ts-ignore reset any ugly mocking we did + crypto.subtleCrypto = realSubtleCrypto; + }); + + it("should generate authorization params", () => { + const result = generateAuthorizationParams({ redirectUri: baseUrl }); + + expect(result.redirectUri).toEqual(baseUrl); + + // random strings + expect(result.state.length).toEqual(8); + expect(result.nonce.length).toEqual(8); + expect(result.codeVerifier.length).toEqual(64); + + const expectedScope = + "openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:"; + expect(result.scope.startsWith(expectedScope)).toBeTruthy(); + // deviceId of 10 characters is appended to the device scope + expect(result.scope.length).toEqual(expectedScope.length + 10); + }); + + describe("generateAuthorizationUrl()", () => { + it("should generate url with correct parameters", async () => { + // test the no crypto case here + // @ts-ignore mocking + crypto.subtleCrypto = undefined; + + const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl }); + const authUrl = new URL( + await generateAuthorizationUrl(authorizationEndpoint, clientId, authorizationParams), + ); + + expect(authUrl.searchParams.get("response_mode")).toEqual("query"); + expect(authUrl.searchParams.get("response_type")).toEqual("code"); + expect(authUrl.searchParams.get("client_id")).toEqual(clientId); + expect(authUrl.searchParams.get("code_challenge_method")).toEqual("S256"); + expect(authUrl.searchParams.get("scope")).toEqual(authorizationParams.scope); + expect(authUrl.searchParams.get("state")).toEqual(authorizationParams.state); + expect(authUrl.searchParams.get("nonce")).toEqual(authorizationParams.nonce); + + // crypto not available, plain text code_challenge is used + expect(authUrl.searchParams.get("code_challenge")).toEqual(authorizationParams.codeVerifier); + expect(logger.warn).toHaveBeenCalledWith( + "A secure context is required to generate code challenge. Using plain text code challenge", + ); + }); + + it("uses a s256 code challenge when crypto is available", async () => { + jest.spyOn(crypto.subtleCrypto, "digest"); + const authorizationParams = generateAuthorizationParams({ redirectUri: baseUrl }); + const authUrl = new URL( + await generateAuthorizationUrl(authorizationEndpoint, clientId, authorizationParams), + ); + + const codeChallenge = authUrl.searchParams.get("code_challenge"); + expect(crypto.subtleCrypto.digest).toHaveBeenCalledWith("SHA-256", expect.any(Object)); + + // didn't use plain text code challenge + expect(authorizationParams.codeVerifier).not.toEqual(codeChallenge); + expect(codeChallenge).toBeTruthy(); + }); + }); + + describe("completeAuthorizationCodeGrant", () => { + const codeVerifier = "abc123"; + const redirectUri = baseUrl; + const code = "auth_code_xyz"; + const validBearerTokenResponse = { + token_type: "Bearer", + access_token: "test_access_token", + refresh_token: "test_refresh_token", + expires_in: 12345, + }; + + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.resetBehavior(); + + fetchMock.post(tokenEndpoint, { + status: 200, + body: JSON.stringify(validBearerTokenResponse), + }); + }); + + it("should make correct request to the token endpoint", async () => { + await completeAuthorizationCodeGrant(code, { clientId, codeVerifier, redirectUri, delegatedAuthConfig }); + + expect(fetchMock).toHaveBeenCalledWith(tokenEndpoint, { + method: Method.Post, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: `grant_type=authorization_code&client_id=${clientId}&code_verifier=${codeVerifier}&redirect_uri=https%3A%2F%2Ftest.com&code=${code}`, + }); + }); + + it("should return with valid bearer token", async () => { + const result = await completeAuthorizationCodeGrant(code, { + clientId, + codeVerifier, + redirectUri, + delegatedAuthConfig, + }); + + expect(result).toEqual(validBearerTokenResponse); + }); + + it("should return with valid bearer token where token_type is lowercase", async () => { + const tokenResponse = { + ...validBearerTokenResponse, + token_type: "bearer", + }; + fetchMock.post( + tokenEndpoint, + { + status: 200, + body: JSON.stringify(tokenResponse), + }, + { overwriteRoutes: true }, + ); + + const result = await completeAuthorizationCodeGrant(code, { + clientId, + codeVerifier, + redirectUri, + delegatedAuthConfig, + }); + + // results in token that uses 'Bearer' token type + expect(result).toEqual(validBearerTokenResponse); + expect(result.token_type).toEqual("Bearer"); + }); + + it("should throw with code exchange failed error when request fails", async () => { + fetchMock.post( + tokenEndpoint, + { + status: 500, + }, + { overwriteRoutes: true }, + ); + await expect(() => + completeAuthorizationCodeGrant(code, { clientId, codeVerifier, redirectUri, delegatedAuthConfig }), + ).rejects.toThrow(new Error(OidcError.CodeExchangeFailed)); + }); + + it("should throw invalid token error when token is invalid", async () => { + const invalidBearerTokenResponse = { + ...validBearerTokenResponse, + access_token: null, + }; + fetchMock.post( + tokenEndpoint, + { status: 200, body: JSON.stringify(invalidBearerTokenResponse) }, + { overwriteRoutes: true }, + ); + await expect(() => + completeAuthorizationCodeGrant(code, { clientId, codeVerifier, redirectUri, delegatedAuthConfig }), + ).rejects.toThrow(new Error(OidcError.InvalidBearerTokenResponse)); + }); + }); +}); diff --git a/src/client.ts b/src/client.ts index c56ea22a17f..a848abba183 100644 --- a/src/client.ts +++ b/src/client.ts @@ -871,6 +871,7 @@ export interface TimestampToEventResponse { interface IWhoamiResponse { user_id: string; device_id?: string; + is_guest?: boolean; } /* eslint-enable camelcase */ diff --git a/src/oidc/authorize.ts b/src/oidc/authorize.ts new file mode 100644 index 00000000000..26211a3d486 --- /dev/null +++ b/src/oidc/authorize.ts @@ -0,0 +1,210 @@ +/* +Copyright 2023 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 { 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 { ValidatedIssuerConfig } from "./validate"; + +/** + * Authorization parameters which are used in the authentication request of an OIDC auth code flow. + * + * See https://openid.net/specs/openid-connect-basic-1_0.html#RequestParameters. + */ +export type AuthorizationParams = { + state: string; + scope: string; + redirectUri: string; + codeVerifier: string; + nonce: string; +}; + +const generateScope = (): string => { + const deviceId = randomString(10); + return `openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:${deviceId}`; +}; + +// https://www.rfc-editor.org/rfc/rfc7636 +const generateCodeChallenge = async (codeVerifier: string): Promise => { + if (!subtleCrypto) { + // @TODO(kerrya) should this be allowed? configurable? + logger.warn("A secure context is required to generate code challenge. Using plain text code challenge"); + return codeVerifier; + } + const utf8 = new TextEncoder().encode(codeVerifier); + + const digest = await subtleCrypto.digest("SHA-256", utf8); + + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +}; + +/** + * Generate authorization params to pass to {@link generateAuthorizationUrl}. + * + * Used as part of an authorization code OIDC flow: see https://openid.net/specs/openid-connect-basic-1_0.html#CodeFlow. + * + * @param redirectUri - absolute url for OP to redirect to after authorization + * @returns AuthorizationParams + */ +export const generateAuthorizationParams = ({ redirectUri }: { redirectUri: string }): AuthorizationParams => ({ + scope: generateScope(), + redirectUri, + state: randomString(8), + nonce: randomString(8), + codeVerifier: randomString(64), // https://tools.ietf.org/html/rfc7636#section-4.1 length needs to be 43-128 characters +}); + +/** + * Generate a URL to attempt authorization with the OP + * See https://openid.net/specs/openid-connect-basic-1_0.html#CodeRequest + * @param authorizationUrl - endpoint to attempt authorization with the OP + * @param clientId - id of this client as registered with the OP + * @param authorizationParams - params to be used in the url + * @returns a Promise with the url as a string + */ +export const generateAuthorizationUrl = async ( + authorizationUrl: string, + clientId: string, + { scope, redirectUri, state, nonce, codeVerifier }: AuthorizationParams, +): Promise => { + const url = new URL(authorizationUrl); + url.searchParams.append("response_mode", "query"); + url.searchParams.append("response_type", "code"); + url.searchParams.append("redirect_uri", redirectUri); + url.searchParams.append("client_id", clientId); + url.searchParams.append("state", state); + url.searchParams.append("scope", scope); + url.searchParams.append("nonce", nonce); + + url.searchParams.append("code_challenge_method", "S256"); + url.searchParams.append("code_challenge", await generateCodeChallenge(codeVerifier)); + + return url.toString(); +}; + +/** + * 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 => + !!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" + * Later, when used in auth headers it is case sensitive and must be Bearer + * See: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.4 + * + * @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"); + +/** + * Attempt to exchange authorization code for bearer token. + * + * Takes the authorization code returned by the OpenID Provider via the authorization URL, and makes a + * request to the Token Endpoint, to obtain the access token, refresh token, etc. + * + * @param code - authorization code as returned by OP during authorization + * @param storedAuthorizationParams - stored params from start of oidc login flow + * @returns valid bearer token response + * @throws when request fails, or returned token response is invalid + */ +export const completeAuthorizationCodeGrant = async ( + code: string, + { + clientId, + codeVerifier, + redirectUri, + delegatedAuthConfig, + }: { + clientId: string; + codeVerifier: string; + redirectUri: string; + delegatedAuthConfig: IDelegatedAuthConfig & ValidatedIssuerConfig; + }, +): Promise => { + 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(); + + const headers = { "Content-Type": "application/x-www-form-urlencoded" }; + + const response = await fetch(delegatedAuthConfig.tokenEndpoint, { + method: Method.Post, + headers, + body: metadata, + }); + + if (response.status >= 400) { + throw new Error(OidcError.CodeExchangeFailed); + } + + const token = await response.json(); + + if (isValidBearerTokenResponse(token)) { + return normalizeBearerTokenResponseTokenType(token); + } + + throw new Error(OidcError.InvalidBearerTokenResponse); +}; diff --git a/src/oidc/error.ts b/src/oidc/error.ts index b77fbbf75f5..6e70283a6ca 100644 --- a/src/oidc/error.ts +++ b/src/oidc/error.ts @@ -22,4 +22,6 @@ export enum OidcError { DynamicRegistrationNotSupported = "Dynamic registration not supported", DynamicRegistrationFailed = "Dynamic registration failed", DynamicRegistrationInvalid = "Dynamic registration invalid response", + CodeExchangeFailed = "Failed to exchange code for token", + InvalidBearerTokenResponse = "Invalid bearer token", }