Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OIDC: navigate to authorization endpoint #3499

Merged
merged 15 commits into from
Jun 26, 2023
183 changes: 183 additions & 0 deletions spec/unit/oidc/authorize.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
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 fetchMockJest from "fetch-mock-jest";
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved

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", () => {
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved
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 validBearerToken = {
token_type: "Bearer",
access_token: "test_access_token",
refresh_token: "test_refresh_token",
expires_in: 12345,
};

beforeEach(() => {
fetchMockJest.mockClear();
fetchMockJest.resetBehavior();

fetchMockJest.post(tokenEndpoint, {
status: 200,
body: JSON.stringify(validBearerToken),
});
});

it("should make correct request to the token endpoint", async () => {
await completeAuthorizationCodeGrant(code, { clientId, codeVerifier, redirectUri, delegatedAuthConfig });

expect(fetchMockJest).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(validBearerToken);
});

it("should throw with code exchange failed error when request fails", async () => {
fetchMockJest.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 invalidBearerToken = {
...validBearerToken,
access_token: null,
};
fetchMockJest.post(
tokenEndpoint,
{ status: 200, body: JSON.stringify(invalidBearerToken) },
{ overwriteRoutes: true },
);
await expect(() =>
completeAuthorizationCodeGrant(code, { clientId, codeVerifier, redirectUri, delegatedAuthConfig }),
).rejects.toThrow(new Error(OidcError.InvalidBearerToken));
});
});
});
1 change: 1 addition & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,7 @@ export interface TimestampToEventResponse {
interface IWhoamiResponse {
user_id: string;
device_id?: string;
is_guest?: boolean;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
/* eslint-enable camelcase */

Expand Down
161 changes: 161 additions & 0 deletions src/oidc/authorize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
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 } from "../crypto/crypto";
import { logger } from "../logger";
import { randomString } from "../randomstring";
import { OidcError } from "./error";
import { ValidatedIssuerConfig } from "./validate";

// See https://openid.net/specs/openid-connect-basic-1_0.html#CodeRequest
type AuthorizationParams = {
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved
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);
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved

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 authorizationEndpoint
* As part of an authorization code OIDC flow
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved
* @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
});

/**
* Generates a URL to attempt authorization with the OP
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved
* 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();
};

export type BearerToken = {
token_type: "Bearer";
access_token: string;
scope: string;
refresh_token?: string;
expires_in?: number;
id_token?: string;
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this whole thing is a bearer token, in OpenID terminology. As I understand it, the "bearer token" per se is just access_token, and obviously this contains a whole bunch of other stuff.

Maybe we can call this BearerTokenResponse, or something?

Suggested change
export type BearerToken = {
token_type: "Bearer";
access_token: string;
scope: string;
refresh_token?: string;
expires_in?: number;
id_token?: string;
};
/**
* The expected response type from the authorization URL for a bearer token.
*
* 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;
};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isValidBearerToken needs a similar rename

const isValidBearerToken = (token: any): token is BearerToken =>
typeof token == "object" &&
token["token_type"] === "Bearer" &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK says "Note that the token_type value is case insensitive."

... which might also be a problem for the type definition?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, added some handling for this

typeof token["access_token"] === "string" &&
(!("refresh_token" in token) || typeof token["refresh_token"] === "string") &&
(!("expires_in" in token) || typeof token["expires_in"] === "number");

/**
* Attempts to exchange authorization code for bearer token
kerryarchibald marked this conversation as resolved.
Show resolved Hide resolved
* @param code - authorization code as returned by OP during authorization
* @param storedAuthorizationParams - stored params from start of oidc login flow
* @returns valid bearer token
* @throws when request fails, or returned token is invalid
*/
export const completeAuthorizationCodeGrant = async (
code: string,
{
clientId,
codeVerifier,
redirectUri,
delegatedAuthConfig,
}: {
clientId: string;
codeVerifier: string;
redirectUri: string;
delegatedAuthConfig: IDelegatedAuthConfig & ValidatedIssuerConfig;
},
): Promise<BearerToken> => {
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 (isValidBearerToken(token)) {
return token;
}

throw new Error(OidcError.InvalidBearerToken);
};
2 changes: 2 additions & 0 deletions src/oidc/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
InvalidBearerToken = "Invalid bearer token",
}
Loading