You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-11-23 17:02:25 +03:00
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>
This commit is contained in:
209
spec/unit/oidc/authorize.spec.ts
Normal file
209
spec/unit/oidc/authorize.spec.ts
Normal file
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -871,6 +871,7 @@ export interface TimestampToEventResponse {
|
||||
interface IWhoamiResponse {
|
||||
user_id: string;
|
||||
device_id?: string;
|
||||
is_guest?: boolean;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
|
||||
|
||||
210
src/oidc/authorize.ts
Normal file
210
src/oidc/authorize.ts
Normal file
@@ -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<string> => {
|
||||
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<string> => {
|
||||
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<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"
|
||||
* 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<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();
|
||||
|
||||
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);
|
||||
};
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user