253 lines
11 KiB
JavaScript
253 lines
11 KiB
JavaScript
import _objectSpread from "@babel/runtime/helpers/objectSpread2";
|
|
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
|
/*
|
|
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 { generateScope, normalizeBearerTokenResponseTokenType, startDeviceAuthorization, validateBearerTokenResponse, waitForDeviceAuthorization } from "./authorize.js";
|
|
import { OAuthGrantType, urlHasCommonBase, validateRegistrationResponse } from "./register.js";
|
|
import { encodeUnpaddedBase64Url } from "../base64.js";
|
|
import { sha256 } from "../digest.js";
|
|
import { HTTPError, isMatrixErrorResponse, MatrixError, Method } from "../http-api/index.js";
|
|
import { logger } from "../logger.js";
|
|
import { isOAuth2ErrorResponse, OAuth2Error, OAuth2HTTPError } from "./error.js";
|
|
import { secureRandomString } from "../randomstring.js";
|
|
export * from "./authorize.js";
|
|
export * from "./error.js";
|
|
export * from "./register.js";
|
|
export * from "./tokenRefresher.js";
|
|
export * from "./discover.js";
|
|
|
|
/**
|
|
* Type representing the persistent context needed for typical OAuth flows
|
|
*/
|
|
|
|
export class OAuth2 {
|
|
/**
|
|
* Attempts dynamic registration against the configured registration endpoint.
|
|
* Will ignore any URIs that do not use client_uri as a common base as per the spec.
|
|
* @param authMetadata - Auth config from {@link MatrixClient.getAuthMetadata}
|
|
* @param clientMetadata - The metadata for the client which to register,
|
|
* grant_types & response_types & token_endpoint_auth_method will be sanely calculated if omitted.
|
|
* @returns Promise<string> resolved with registered clientId
|
|
* @throws when registration is not supported, on failed request or invalid response
|
|
*/
|
|
static async registerClient(authMetadata, clientMetadata) {
|
|
const defaultGrantTypes = [OAuthGrantType.AuthorizationCode, OAuthGrantType.RefreshToken];
|
|
// ask for device authorization grant if supported
|
|
if (authMetadata.grant_types_supported.includes(OAuthGrantType.DeviceAuthorization)) {
|
|
defaultGrantTypes.push(OAuthGrantType.DeviceAuthorization);
|
|
}
|
|
const grantTypes = clientMetadata.grant_types ?? defaultGrantTypes;
|
|
if (grantTypes.some(scope => !authMetadata.grant_types_supported.includes(scope))) {
|
|
throw new Error(OAuth2Error.DynamicRegistrationNotSupported);
|
|
}
|
|
const commonBase = new URL(clientMetadata.client_uri);
|
|
const request = _objectSpread(_objectSpread({
|
|
// Apply some defaults
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none"
|
|
}, clientMetadata), {}, {
|
|
grant_types: grantTypes,
|
|
logo_uri: urlHasCommonBase(commonBase, clientMetadata.logo_uri) ? clientMetadata.logo_uri : undefined,
|
|
policy_uri: urlHasCommonBase(commonBase, clientMetadata.policy_uri) ? clientMetadata.policy_uri : undefined,
|
|
tos_uri: urlHasCommonBase(commonBase, clientMetadata.tos_uri) ? clientMetadata.tos_uri : undefined
|
|
});
|
|
try {
|
|
const response = await fetch(authMetadata.registration_endpoint, {
|
|
method: Method.Post,
|
|
headers: {
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json"
|
|
},
|
|
body: JSON.stringify(request)
|
|
});
|
|
if (response.status >= 400) {
|
|
throw new Error(OAuth2Error.DynamicRegistrationFailed);
|
|
}
|
|
const registrationResponse = await response.json();
|
|
if (validateRegistrationResponse(registrationResponse)) {
|
|
return registrationResponse.client_id;
|
|
}
|
|
throw new Error(OAuth2Error.DynamicRegistrationInvalid);
|
|
} catch (error) {
|
|
if (Object.values(OAuth2Error).includes(error.message)) {
|
|
throw error;
|
|
} else {
|
|
logger.error("Dynamic registration request failed", error);
|
|
throw new Error(OAuth2Error.DynamicRegistrationFailed, {
|
|
cause: error
|
|
});
|
|
}
|
|
}
|
|
}
|
|
constructor(metadata, context) {
|
|
_defineProperty(this, "context", void 0);
|
|
this.metadata = metadata;
|
|
this.context = {
|
|
clientId: context.clientId,
|
|
redirectUri: context.redirectUri,
|
|
deviceId: context.deviceId ?? secureRandomString(10),
|
|
codeVerifier: context.codeVerifier ?? secureRandomString(96)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate a URL to attempt authorization with the OP
|
|
* See https://spec.matrix.org/v1.18/client-server-api/#authorization-code-flow
|
|
* @param state - A unique opaque identifier, like a transaction ID,
|
|
* that will allow the client to maintain state between the authorization request and the callback.
|
|
* The app should use this to key the storage for where the rest of the auth context is saved.
|
|
* @param responseMode - The manner in which the IdP should send the secrets back to the app. Defaults to `fragment` for privacy.
|
|
* @param prompt - Optional prompt parameter to pass to the IdP to signal intent, e.g. `create` for User registration.
|
|
* @param scope - The OAuth2 scope to request, will be generated based on the device ID if omitted.
|
|
* @returns a Promise with the url as a string
|
|
*/
|
|
async generateAuthorizationCodeGrantUrl(state, responseMode = "fragment", prompt, scope) {
|
|
const challenge = encodeUnpaddedBase64Url(await sha256(this.context.codeVerifier));
|
|
const url = new URL(this.metadata.authorization_endpoint);
|
|
url.searchParams.set("response_type", "code");
|
|
url.searchParams.set("response_mode", responseMode);
|
|
url.searchParams.set("client_id", this.context.clientId);
|
|
url.searchParams.set("redirect_uri", this.context.redirectUri);
|
|
url.searchParams.set("scope", scope ?? generateScope(this.context.deviceId));
|
|
url.searchParams.set("state", state);
|
|
url.searchParams.set("code_challenge_method", "S256");
|
|
url.searchParams.set("code_challenge", challenge);
|
|
if (prompt) {
|
|
url.searchParams.set("prompt", prompt);
|
|
}
|
|
return url.toString();
|
|
}
|
|
|
|
/**
|
|
* Attempt to exchange authorization code for bearer token.
|
|
*
|
|
* Takes the authorization code returned by the OAuth2 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 IdP during authorization
|
|
* @returns a validated bearer token response
|
|
* @throws An `Error` with `message` set to an entry in {@link OAuth2Error},
|
|
* when the request fails, or the returned token response is invalid.
|
|
*/
|
|
async completeAuthorizationCodeGrant(code) {
|
|
const params = new URLSearchParams();
|
|
params.append("grant_type", "authorization_code");
|
|
params.append("client_id", this.context.clientId);
|
|
params.append("code_verifier", this.context.codeVerifier);
|
|
params.append("redirect_uri", this.context.redirectUri);
|
|
params.append("code", code);
|
|
const tokenResponse = await this.fetch("token", params, OAuth2Error.CodeExchangeFailed);
|
|
|
|
// throws when response is invalid
|
|
validateBearerTokenResponse(tokenResponse);
|
|
return normalizeBearerTokenResponseTokenType(tokenResponse);
|
|
}
|
|
|
|
/**
|
|
* Refresh the access token using the given refresh token and the refresh token grant
|
|
* @param refreshToken - the token to use to refresh the access token
|
|
*/
|
|
async performRefreshTokenGrant(refreshToken) {
|
|
const params = new URLSearchParams();
|
|
params.append("grant_type", "refresh_token");
|
|
params.append("client_id", this.context.clientId);
|
|
params.append("refresh_token", refreshToken);
|
|
const tokenResponse = await this.fetch("token", params, OAuth2Error.RefreshTokenFailed);
|
|
|
|
// throws when response is invalid
|
|
validateBearerTokenResponse(tokenResponse);
|
|
return normalizeBearerTokenResponseTokenType(tokenResponse);
|
|
}
|
|
|
|
/**
|
|
* Revokes the given token
|
|
* @param token - the token to remove
|
|
* @param type - the type of token, acts as a hint to the IdP
|
|
*/
|
|
async revokeToken(token, type) {
|
|
const params = new URLSearchParams();
|
|
params.append("token", token);
|
|
params.append("client_id", this.context.clientId);
|
|
if (type) {
|
|
params.append("token_type_hint", type);
|
|
}
|
|
await this.fetch("revocation", params, OAuth2Error.RevokeTokenFailed);
|
|
const headers = new Headers();
|
|
headers.set("Content-Type", "application/x-www-form-urlencoded");
|
|
}
|
|
|
|
/**
|
|
* Begin OAuth2 device authorization flow.
|
|
* @param scope - the scope to request for authorization.
|
|
* @returns a promise that resolves to a device access token response,
|
|
* or an error response if the user denies authorization or the device code expires.
|
|
*/
|
|
async startDeviceAuthorizationGrant(scope) {
|
|
return startDeviceAuthorization({
|
|
scope: scope ?? generateScope(this.context.deviceId),
|
|
metadata: this.metadata,
|
|
clientId: this.context.clientId
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Polls the OAuth2 token endpoint until we get a device access token response, or encounter an unrecoverable error.
|
|
* @param session - The session returned from a previous call to {@link OAuth2.startDeviceAuthorizationGrant}.
|
|
* @returns a promise that resolves to a device access token response,
|
|
* or an error response if the user denies authorization or the device code expires.
|
|
*/
|
|
async waitForDeviceAuthorizationGrant(session) {
|
|
return waitForDeviceAuthorization({
|
|
session,
|
|
metadata: this.metadata,
|
|
clientId: this.context.clientId
|
|
});
|
|
}
|
|
async fetch(target, params, error) {
|
|
const url = this.metadata[`${target}_endpoint`];
|
|
const res = await fetch(url, {
|
|
method: Method.Post,
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"Accept": "application/json"
|
|
},
|
|
body: params
|
|
});
|
|
if (res.status >= 400) {
|
|
let body;
|
|
try {
|
|
body = await res.json();
|
|
} catch {
|
|
// The endpoint didn't give us a JSON body, so we can't determine the error type. We'll throw a generic
|
|
// HTTPError below.
|
|
}
|
|
// Because the Matrix C-S API error response format is so similar to the OAuth 2.0 error response format
|
|
// the ordering of these checks is important. We want to check for a Matrix error response first, and only
|
|
// if it isn't one do we check for an OAuth 2.0 error response.
|
|
// This essentially relies on `errcode` not being present in an OAuth 2.0 error response.
|
|
if (isMatrixErrorResponse(body)) {
|
|
throw new MatrixError(body, res.status, undefined, undefined, res.headers);
|
|
}
|
|
if (isOAuth2ErrorResponse(body)) {
|
|
throw new OAuth2HTTPError(error, res.status, res.headers, body);
|
|
}
|
|
throw new HTTPError(error, res.status, res.headers);
|
|
}
|
|
return await res.json();
|
|
}
|
|
}
|
|
//# sourceMappingURL=index.js.map
|