193 lines
7.5 KiB
JavaScript
193 lines
7.5 KiB
JavaScript
import _objectSpread from "@babel/runtime/helpers/objectSpread2";
|
|
/*
|
|
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 { secureRandomString } from "../randomstring.js";
|
|
import { OAuth2Error } from "./error.js";
|
|
import { hasOptionalNumberProperty, hasOptionalStringProperty, hasRequiredNumberProperty, hasRequiredStringProperty, isRecord } from "../@types/type-guards.js";
|
|
import { Method } from "../http-api/index.js";
|
|
import { OAuthGrantType } from "./register.js";
|
|
import { sleep } from "../utils.js";
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
|
|
/**
|
|
* Metadata from OAuth 2.0 token_endpoint as per
|
|
* https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
|
|
* With validated properties required in type
|
|
*
|
|
* This response is expected for the authorization code grant and refresh token grant,
|
|
* as defined in the Matrix spec.
|
|
*/
|
|
|
|
/**
|
|
* Validate the given response matches the format expected for a {@link ValidTokenResponse}
|
|
* @param response - the response to validate
|
|
* @throws if the response does not match the expected format
|
|
*/
|
|
export function validateBearerTokenResponse(response) {
|
|
if (!isRecord(response) || !hasRequiredStringProperty(response, "token_type") ||
|
|
// token_type is case-insensitive, some OPs return `token_type: "bearer"`
|
|
response["token_type"].toLowerCase() !== "bearer" || !hasRequiredStringProperty(response, "access_token") || !hasOptionalNumberProperty(response, "expires_in") || !hasOptionalStringProperty(response, "refresh_token") || !hasOptionalStringProperty(response, "scope")) {
|
|
throw new Error(OAuth2Error.InvalidBearerTokenResponse);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate the scope used in authorization request with OAuth2 IdP
|
|
* @returns scope
|
|
*/
|
|
export const generateScope = deviceId => {
|
|
const safeDeviceId = deviceId ?? secureRandomString(10);
|
|
return `urn:matrix:client:api:* urn:matrix:client:device:${safeDeviceId}`;
|
|
};
|
|
|
|
/**
|
|
* 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'
|
|
*/
|
|
export const normalizeBearerTokenResponseTokenType = response => _objectSpread(_objectSpread({}, response), {}, {
|
|
token_type: "Bearer"
|
|
});
|
|
|
|
/**
|
|
* Response from the OAuth2 token endpoint when exchanging a token for grant_type device_code.
|
|
*/
|
|
|
|
/**
|
|
* Validate the given response matches the format expected for a {@link DeviceAccessTokenResponse}
|
|
* @param response - the response to validate
|
|
* @throws if the response does not match the expected format
|
|
*/
|
|
export function isValidDeviceAccessTokenResponse(response) {
|
|
return isRecord(response) && hasRequiredStringProperty(response, "access_token") && hasRequiredStringProperty(response, "token_type") && hasOptionalStringProperty(response, "refresh_token") && hasOptionalStringProperty(response, "scope") && hasOptionalNumberProperty(response, "expires_in");
|
|
}
|
|
|
|
/**
|
|
* Error from the OAuth2 token endpoint when exchanging a token for grant_type device_code.
|
|
*/
|
|
|
|
/**
|
|
* Response from the OAuth2 device authorization endpoint.
|
|
* As specified in https://datatracker.ietf.org/doc/html/rfc8628#section-3.2
|
|
*/
|
|
|
|
/**
|
|
* Validate the given response matches the format expected for a {@link DeviceAuthorizationResponse}
|
|
* @param response - the response to validate
|
|
* @throws if the response does not match the expected format
|
|
*/
|
|
export function validateDeviceAuthorizationResponse(response) {
|
|
if (!isRecord(response) || !hasRequiredStringProperty(response, "device_code") || !hasRequiredStringProperty(response, "user_code") || !hasRequiredStringProperty(response, "verification_uri") || !hasRequiredNumberProperty(response, "expires_in") || !hasOptionalStringProperty(response, "verification_uri_complete") || !hasOptionalNumberProperty(response, "interval")) {
|
|
throw new Error(OAuth2Error.InvalidDeviceAuthorizationResponse);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Begin OAuth2 device authorization flow.
|
|
* @param options - The device authorization parameters.
|
|
* @param options.clientId - the client ID returned from client registration.
|
|
* @param options.scope - the scope to request for authorization.
|
|
* @param options.metadata - the validated OAuth2 metadata for the Identity Provider.
|
|
* @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.
|
|
*/
|
|
export const startDeviceAuthorization = async ({
|
|
clientId,
|
|
scope,
|
|
metadata
|
|
}) => {
|
|
const body = new URLSearchParams({
|
|
client_id: clientId,
|
|
scope: scope
|
|
}).toString();
|
|
const url = metadata.device_authorization_endpoint;
|
|
if (!url) {
|
|
throw new Error("No device_authorization_endpoint given");
|
|
}
|
|
const response = await fetch(url, {
|
|
method: Method.Post,
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded"
|
|
},
|
|
body
|
|
});
|
|
const data = await response.json();
|
|
validateDeviceAuthorizationResponse(data);
|
|
return data;
|
|
};
|
|
|
|
/**
|
|
* Polls the OAuth2 token endpoint until we get a device access token response, or encounter an unrecoverable error.
|
|
* @param options - The device authorization parameters.
|
|
* @param options.session - The session returned from a previous call to {@link startDeviceAuthorization}.
|
|
* @param options.metadata - The validated OAuth2 metadata for the Identity Provider.
|
|
* @param options.clientId - The client ID returned from client registration.
|
|
* @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.
|
|
*/
|
|
export const waitForDeviceAuthorization = async ({
|
|
session,
|
|
metadata,
|
|
clientId
|
|
}) => {
|
|
let interval = (session.interval ?? 5) * 1000; // poll interval
|
|
const expiration = Date.now() + session.expires_in * 1000;
|
|
do {
|
|
const body = new URLSearchParams({
|
|
device_code: session.device_code,
|
|
grant_type: OAuthGrantType.DeviceAuthorization,
|
|
client_id: clientId
|
|
}).toString();
|
|
const response = await fetch(metadata.token_endpoint, {
|
|
method: Method.Post,
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded"
|
|
},
|
|
body
|
|
});
|
|
const data = await response.json();
|
|
if (response.ok && isValidDeviceAccessTokenResponse(data)) {
|
|
return data;
|
|
}
|
|
const errorResponse = data;
|
|
switch (errorResponse.error) {
|
|
case "authorization_pending":
|
|
break;
|
|
case "slow_down":
|
|
interval += 5000;
|
|
break;
|
|
case "access_denied":
|
|
case "expired_token":
|
|
return errorResponse;
|
|
}
|
|
await sleep(interval);
|
|
} while (Date.now() < expiration);
|
|
return {
|
|
error: "expired"
|
|
};
|
|
};
|
|
//# sourceMappingURL=authorize.js.map
|