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 {
|
interface IWhoamiResponse {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
device_id?: string;
|
device_id?: string;
|
||||||
|
is_guest?: boolean;
|
||||||
}
|
}
|
||||||
/* eslint-enable camelcase */
|
/* 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",
|
DynamicRegistrationNotSupported = "Dynamic registration not supported",
|
||||||
DynamicRegistrationFailed = "Dynamic registration failed",
|
DynamicRegistrationFailed = "Dynamic registration failed",
|
||||||
DynamicRegistrationInvalid = "Dynamic registration invalid response",
|
DynamicRegistrationInvalid = "Dynamic registration invalid response",
|
||||||
|
CodeExchangeFailed = "Failed to exchange code for token",
|
||||||
|
InvalidBearerTokenResponse = "Invalid bearer token",
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user