init
This commit is contained in:
271
node_modules/matrix-js-sdk/src/oauth/authorize.ts
generated
vendored
Normal file
271
node_modules/matrix-js-sdk/src/oauth/authorize.ts
generated
vendored
Normal file
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
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.ts";
|
||||
import { OAuth2Error, type OAuth2ErrorResponse } from "./error.ts";
|
||||
import { type ValidatedAuthMetadata } from "./discover.ts";
|
||||
import {
|
||||
hasOptionalNumberProperty,
|
||||
hasOptionalStringProperty,
|
||||
hasRequiredNumberProperty,
|
||||
hasRequiredStringProperty,
|
||||
isRecord,
|
||||
} from "../@types/type-guards.ts";
|
||||
import { Method } from "../http-api/index.ts";
|
||||
import { OAuthGrantType } from "./register.ts";
|
||||
import { sleep } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export type BearerTokenResponse = Omit<ValidTokenResponse, "token_type"> & {
|
||||
token_type: "Bearer";
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
interface ValidTokenResponse {
|
||||
token_type: "Bearer" | "bearer";
|
||||
access_token: string;
|
||||
expires_in?: number;
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: unknown): asserts response is ValidTokenResponse {
|
||||
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?: string): string => {
|
||||
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: ValidTokenResponse): BearerTokenResponse => ({
|
||||
...response,
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
/**
|
||||
* Response from the OAuth2 token endpoint when exchanging a token for grant_type device_code.
|
||||
*/
|
||||
export interface DeviceAccessTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: unknown): response is DeviceAccessTokenResponse {
|
||||
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.
|
||||
*/
|
||||
export interface DeviceAccessTokenError extends OAuth2ErrorResponse {
|
||||
session_state?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from the OAuth2 device authorization endpoint.
|
||||
* As specified in https://datatracker.ietf.org/doc/html/rfc8628#section-3.2
|
||||
*/
|
||||
export interface DeviceAuthorizationResponse {
|
||||
/** The device verification code. */
|
||||
device_code: string;
|
||||
/** The end-user verification code. */
|
||||
user_code: string;
|
||||
/**
|
||||
* The end-user verification URI on the authorization server.
|
||||
* The URI should be short and easy to remember as end users will be asked to manually type it into their user agent.
|
||||
*/
|
||||
verification_uri: string;
|
||||
/**
|
||||
* The URI which doesn’t require the user to manually type the user_code, designed for non-textual transmission.
|
||||
*/
|
||||
verification_uri_complete?: string;
|
||||
/** The lifetime in seconds of the "device_code" and "user_code". */
|
||||
expires_in: number;
|
||||
/**
|
||||
* The minimum amount of time in seconds that the client SHOULD wait between polling requests to the token endpoint.
|
||||
* If no value is provided, clients MUST use 5 as the default.
|
||||
*/
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: unknown,
|
||||
): asserts response is DeviceAuthorizationResponse {
|
||||
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,
|
||||
}: {
|
||||
clientId: string;
|
||||
scope: string;
|
||||
metadata: ValidatedAuthMetadata;
|
||||
}): Promise<DeviceAuthorizationResponse> => {
|
||||
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,
|
||||
}: {
|
||||
session: DeviceAuthorizationResponse;
|
||||
metadata: ValidatedAuthMetadata;
|
||||
clientId: string;
|
||||
}): Promise<DeviceAccessTokenResponse | DeviceAccessTokenError> => {
|
||||
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 as DeviceAccessTokenError;
|
||||
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" };
|
||||
};
|
||||
102
node_modules/matrix-js-sdk/src/oauth/discover.ts
generated
vendored
Normal file
102
node_modules/matrix-js-sdk/src/oauth/discover.ts
generated
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
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 {
|
||||
hasOptionalStringProperty,
|
||||
hasRequiredStringProperty,
|
||||
isRecord,
|
||||
optionalStringArrayProperty,
|
||||
requiredArrayValue,
|
||||
} from "../@types/type-guards.ts";
|
||||
import { OAuthGrantType } from "./index.ts";
|
||||
|
||||
/**
|
||||
* Metadata from OAuth 2.0 client authentication API as per
|
||||
* https://spec.matrix.org/v1.18/client-server-api/#get_matrixclientv1auth_metadata
|
||||
* With validated properties required in type
|
||||
*/
|
||||
export interface ValidatedAuthMetadata {
|
||||
/** List of actions that the account management URL supports. */
|
||||
account_management_actions_supported?: string[];
|
||||
/** The URL where the user is able to access the account management capabilities of the homeserver. */
|
||||
account_management_uri?: string;
|
||||
/** URL of the authorization endpoint, necessary to use the authorization code grant. */
|
||||
authorization_endpoint: string;
|
||||
/**
|
||||
* List of OAuth 2.0 Proof Key for Code Exchange (PKCE) code challenge methods that the server supports at the authorization endpoint.
|
||||
*
|
||||
* This array MUST contain at least the S256 value, for improved security in the authorization code grant.
|
||||
*/
|
||||
code_challenge_methods_supported: string[];
|
||||
/** URL of the device authorization endpoint, as defined in RFC 8628, necessary to use the device authorization grant. */
|
||||
device_authorization_endpoint?: string;
|
||||
/**
|
||||
* List of OAuth 2.0 grant type strings that the server supports at the token endpoint.
|
||||
*
|
||||
* This array MUST contain at least the authorization_code and refresh_token values,
|
||||
* for clients to be able to use the authorization code grant and refresh token grant, respectively.
|
||||
*/
|
||||
grant_types_supported: string[];
|
||||
/** The authorization server’s issuer identifier, which is a URL that uses the https scheme and has no query or fragment components. */
|
||||
issuer: string;
|
||||
/** List of OpenID Connect prompt values that the server supports at the authorization endpoint. */
|
||||
prompt_values_supported?: string[];
|
||||
/** URL of the client registration endpoint, necessary to perform dynamic registration of a client. */
|
||||
registration_endpoint: string;
|
||||
/**
|
||||
* List of OAuth 2.0 response mode strings that the server supports at the authorization endpoint.
|
||||
*
|
||||
* This array MUST contain at least the query and fragment values, for improved security in the authorization code grant.
|
||||
*/
|
||||
response_modes_supported: string[];
|
||||
/**
|
||||
* List of OAuth 2.0 response type strings that the server supports at the authorization endpoint.
|
||||
*
|
||||
* This array MUST contain at least the code value, for clients to be able to use the authorization code grant.
|
||||
*/
|
||||
response_types_supported: string[];
|
||||
/** URL of the revocation endpoint, necessary to log out a client by invalidating its access and refresh tokens. */
|
||||
revocation_endpoint: string;
|
||||
/** URL of the token endpoint, used by the grants. */
|
||||
token_endpoint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates OAuth 2.0 auth metadata as defined by
|
||||
* https://spec.matrix.org/v1.18/client-server-api/#get_matrixclientv1auth_metadata
|
||||
* @param authMetadata - json object
|
||||
* @returns boolean of whether the input is valid
|
||||
*/
|
||||
export const isValidAuthMetadata = (authMetadata: unknown): authMetadata is ValidatedAuthMetadata => {
|
||||
return (
|
||||
isRecord(authMetadata) &&
|
||||
hasRequiredStringProperty(authMetadata, "issuer") &&
|
||||
hasRequiredStringProperty(authMetadata, "authorization_endpoint") &&
|
||||
hasRequiredStringProperty(authMetadata, "token_endpoint") &&
|
||||
hasRequiredStringProperty(authMetadata, "revocation_endpoint") &&
|
||||
hasRequiredStringProperty(authMetadata, "registration_endpoint") &&
|
||||
hasOptionalStringProperty(authMetadata, "account_management_uri") &&
|
||||
hasOptionalStringProperty(authMetadata, "device_authorization_endpoint") &&
|
||||
optionalStringArrayProperty(authMetadata, "account_management_actions_supported") &&
|
||||
optionalStringArrayProperty(authMetadata, "prompt_values_supported") &&
|
||||
requiredArrayValue(authMetadata, "response_modes_supported", "query") &&
|
||||
requiredArrayValue(authMetadata, "response_modes_supported", "fragment") &&
|
||||
requiredArrayValue(authMetadata, "response_types_supported", "code") &&
|
||||
requiredArrayValue(authMetadata, "grant_types_supported", OAuthGrantType.AuthorizationCode) &&
|
||||
requiredArrayValue(authMetadata, "grant_types_supported", OAuthGrantType.RefreshToken) &&
|
||||
requiredArrayValue(authMetadata, "code_challenge_methods_supported", "S256")
|
||||
);
|
||||
};
|
||||
103
node_modules/matrix-js-sdk/src/oauth/error.ts
generated
vendored
Normal file
103
node_modules/matrix-js-sdk/src/oauth/error.ts
generated
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
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 { hasOptionalStringProperty, hasRequiredStringProperty, isRecord } from "../@types/type-guards.ts";
|
||||
import { HTTPError } from "../http-api/errors.ts";
|
||||
|
||||
/**
|
||||
* Errors expected to be encountered during OAuth2 discovery, client registration, and authentication.
|
||||
* Not intended to be displayed directly to the user.
|
||||
*/
|
||||
export enum OAuth2Error {
|
||||
General = "Something went wrong with OAuth2 discovery",
|
||||
OpSupport = "Configured OAuth2 OP does not support required functions",
|
||||
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 response",
|
||||
InvalidDeviceAuthorizationResponse = "Invalid device authorization response",
|
||||
MissingOrInvalidStoredState = "State required to finish logging in is not found in storage.",
|
||||
RefreshTokenFailed = "Failed to refresh token",
|
||||
RevokeTokenFailed = "Failed to revoke token",
|
||||
DeviceAuthorizationGrantFailed = "Failed to perform device authorization grant",
|
||||
}
|
||||
|
||||
/**
|
||||
* An error response from an OAuth 2.0 endpoint,
|
||||
* as specified in https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
||||
*/
|
||||
export interface OAuth2ErrorResponse {
|
||||
/** A single ASCII error code, e.g. `invalid_grant`. */
|
||||
error: string;
|
||||
/** Human-readable ASCII text providing additional information about the error. */
|
||||
error_description?: string;
|
||||
/** A URI identifying a human-readable web page with information about the error. */
|
||||
error_uri?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given (JSON-parsed) response body is an OAuth 2.0 error response
|
||||
* as specified in https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
||||
* @param response - the parsed response body to check
|
||||
* @returns whether the response is a valid {@link OAuth2ErrorResponse}
|
||||
*/
|
||||
export function isOAuth2ErrorResponse(response: unknown): response is OAuth2ErrorResponse {
|
||||
return (
|
||||
isRecord(response) &&
|
||||
hasRequiredStringProperty(response, "error") &&
|
||||
hasOptionalStringProperty(response, "error_description") &&
|
||||
hasOptionalStringProperty(response, "error_uri")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An error thrown when a request to an OAuth 2.0 endpoint fails with a body matching the error
|
||||
* response format specified in [RFC 6749 section 5.2](https://datatracker.ietf.org/doc/html/rfc6749#section-5.2).
|
||||
*/
|
||||
export class OAuth2HTTPError extends HTTPError implements OAuth2ErrorResponse {
|
||||
/**
|
||||
* RFC 6749 section 5.2 error code, e.g. `invalid_grant`
|
||||
*
|
||||
* IANA matains a registry of valid values at
|
||||
* https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml#extensions-error
|
||||
*/
|
||||
public error: string;
|
||||
|
||||
/**
|
||||
* RFC 6749 section 5.2 human-readable ASCII text providing additional information about the error.
|
||||
* This field is optional and may be omitted by the endpoint.
|
||||
*/
|
||||
public error_description?: string;
|
||||
|
||||
/**
|
||||
* RFC 6749 section 5.2 URI identifying a human-readable web page with information about the error.
|
||||
* This field is optional and may be omitted by the endpoint.
|
||||
*/
|
||||
public error_uri?: string;
|
||||
|
||||
public constructor(
|
||||
msg: string,
|
||||
httpStatus: number | undefined,
|
||||
httpHeaders: Headers | undefined,
|
||||
{ error, error_description, error_uri }: OAuth2ErrorResponse,
|
||||
) {
|
||||
super(msg, httpStatus, httpHeaders);
|
||||
this.error = error;
|
||||
this.error_description = error_description;
|
||||
this.error_uri = error_uri;
|
||||
}
|
||||
}
|
||||
314
node_modules/matrix-js-sdk/src/oauth/index.ts
generated
vendored
Normal file
314
node_modules/matrix-js-sdk/src/oauth/index.ts
generated
vendored
Normal file
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
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 {
|
||||
type BearerTokenResponse,
|
||||
type DeviceAccessTokenError,
|
||||
type DeviceAccessTokenResponse,
|
||||
type DeviceAuthorizationResponse,
|
||||
generateScope,
|
||||
normalizeBearerTokenResponseTokenType,
|
||||
startDeviceAuthorization,
|
||||
validateBearerTokenResponse,
|
||||
waitForDeviceAuthorization,
|
||||
} from "./authorize.ts";
|
||||
import type { ValidatedAuthMetadata } from "./discover.ts";
|
||||
import {
|
||||
OAuthGrantType,
|
||||
type OAuthRegistrationRequest,
|
||||
urlHasCommonBase,
|
||||
validateRegistrationResponse,
|
||||
} from "./register.ts";
|
||||
import { encodeUnpaddedBase64Url } from "../base64.ts";
|
||||
import { sha256 } from "../digest.ts";
|
||||
import { HTTPError, isMatrixErrorResponse, MatrixError, Method } from "../http-api/index.ts";
|
||||
import { logger } from "../logger.ts";
|
||||
import { isOAuth2ErrorResponse, OAuth2Error, OAuth2HTTPError } from "./error.ts";
|
||||
import { secureRandomString } from "../randomstring.ts";
|
||||
import { type NonEmptyArray } from "../@types/common.ts";
|
||||
|
||||
export * from "./authorize.ts";
|
||||
export * from "./error.ts";
|
||||
export * from "./register.ts";
|
||||
export * from "./tokenRefresher.ts";
|
||||
export * from "./discover.ts";
|
||||
|
||||
/**
|
||||
* Type representing the persistent context needed for typical OAuth flows
|
||||
*/
|
||||
type Context = {
|
||||
/** The OAuth client ID */
|
||||
clientId: string;
|
||||
/** The desired device ID */
|
||||
deviceId?: string;
|
||||
/** The seed used to generate the challenge code */
|
||||
codeVerifier?: string;
|
||||
/** The URI to redirect the user to with credentials after auth */
|
||||
redirectUri: string;
|
||||
};
|
||||
|
||||
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
|
||||
*/
|
||||
public static async registerClient(
|
||||
authMetadata: ValidatedAuthMetadata,
|
||||
clientMetadata: OAuthRegistrationRequest,
|
||||
): Promise<string> {
|
||||
const defaultGrantTypes: NonEmptyArray<string> = [
|
||||
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: OAuthRegistrationRequest = {
|
||||
// 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 as Error).message as OAuth2Error)) {
|
||||
throw error;
|
||||
} else {
|
||||
logger.error("Dynamic registration request failed", error);
|
||||
throw new Error(OAuth2Error.DynamicRegistrationFailed, { cause: error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public readonly context: Required<Context>;
|
||||
|
||||
public constructor(
|
||||
public readonly metadata: ValidatedAuthMetadata,
|
||||
context: Context,
|
||||
) {
|
||||
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
|
||||
*/
|
||||
public async generateAuthorizationCodeGrantUrl(
|
||||
state: string,
|
||||
responseMode: "fragment" | "query" = "fragment",
|
||||
prompt?: string,
|
||||
scope?: string,
|
||||
): Promise<string> {
|
||||
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.
|
||||
*/
|
||||
public async completeAuthorizationCodeGrant(code: string): Promise<BearerTokenResponse> {
|
||||
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
|
||||
*/
|
||||
public async performRefreshTokenGrant(refreshToken: string): Promise<BearerTokenResponse> {
|
||||
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
|
||||
*/
|
||||
public async revokeToken(token: string, type?: "access_token" | "refresh_token"): Promise<void> {
|
||||
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.
|
||||
*/
|
||||
public async startDeviceAuthorizationGrant(scope?: string): Promise<DeviceAuthorizationResponse> {
|
||||
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.
|
||||
*/
|
||||
public async waitForDeviceAuthorizationGrant(
|
||||
session: DeviceAuthorizationResponse,
|
||||
): Promise<DeviceAccessTokenResponse | DeviceAccessTokenError> {
|
||||
return waitForDeviceAuthorization({
|
||||
session,
|
||||
metadata: this.metadata,
|
||||
clientId: this.context.clientId,
|
||||
});
|
||||
}
|
||||
|
||||
private async fetch(
|
||||
target: "token" | "registration" | "revocation",
|
||||
params: URLSearchParams,
|
||||
error: OAuth2Error,
|
||||
): Promise<unknown> {
|
||||
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: unknown;
|
||||
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();
|
||||
}
|
||||
}
|
||||
166
node_modules/matrix-js-sdk/src/oauth/register.ts
generated
vendored
Normal file
166
node_modules/matrix-js-sdk/src/oauth/register.ts
generated
vendored
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
Copyright 2023-2026 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 { type NonEmptyArray } from "../@types/common.ts";
|
||||
import { hasRequiredStringProperty, isRecord } from "../@types/type-guards.ts";
|
||||
|
||||
type LocalizableKeys = "client_name" | "client_uri" | "policy_uri" | "tos_uri" | "logo_uri";
|
||||
|
||||
/**
|
||||
* Request body for dynamic registration as defined by https://spec.matrix.org/v1.18/client-server-api/#client-registration
|
||||
*/
|
||||
export type OAuthRegistrationRequest = {
|
||||
/**
|
||||
* Kind of the application.
|
||||
*
|
||||
* The homeserver MUST support the web and native values to be able to perform redirect URI validation.
|
||||
*
|
||||
* Defaults to web if omitted.
|
||||
*/
|
||||
application_type?: "web" | "native";
|
||||
/**
|
||||
* Human-readable name of the client to be presented to the user.
|
||||
*
|
||||
* This field can be localized by specifying `client_name#$lang`.
|
||||
*/
|
||||
client_name?: string;
|
||||
/**
|
||||
* A URL to a valid web page that SHOULD give the user more information about the client.
|
||||
*
|
||||
* This URL MUST use the https scheme and SHOULD NOT require authentication to access.
|
||||
* It MUST NOT use a user or password in the authority component of the URI.
|
||||
*
|
||||
* The server MAY reject client registrations if this field is invalid or missing.
|
||||
*
|
||||
* This URI is a common base for all the other URIs in the metadata:
|
||||
* those MUST be either on the same host or on a subdomain of the host of the client_uri.
|
||||
* The port number, path and query components MAY be different.
|
||||
*
|
||||
* For example, if the client_uri is https://example.com/,
|
||||
* then one of the redirect_uris can be https://example.com/callback or https://app.example.com/callback,
|
||||
* but not https://app.com/callback.
|
||||
*
|
||||
* This field can be localized by specifying `client_uri#$lang`.
|
||||
*/
|
||||
client_uri: string;
|
||||
/**
|
||||
* Array of the OAuth 2.0 grant types that the client may use.
|
||||
*
|
||||
* This MUST include:
|
||||
*
|
||||
* the authorization_code value to use the authorization code grant,
|
||||
* the refresh_token value to use the refresh token grant.
|
||||
*/
|
||||
grant_types?: NonEmptyArray<string>;
|
||||
/**
|
||||
* URL that references a logo for the client.
|
||||
*
|
||||
* This URL MUST use the https scheme.
|
||||
*
|
||||
* This field can be localized by specifying `logo_uri#$lang`.
|
||||
*/
|
||||
logo_uri?: string;
|
||||
/**
|
||||
* URL that points to a human-readable policy document for the client.
|
||||
*
|
||||
* This URL MUST use the https scheme and SHOULD NOT require authentication to access.
|
||||
* It MUST NOT use a user or password in the authority component of the URI.
|
||||
*
|
||||
* This field can be localized by specifying `policy_uri#$lang`.
|
||||
*/
|
||||
policy_uri?: string;
|
||||
/**
|
||||
* Array of redirection URIs for use in redirect-based flows.
|
||||
*
|
||||
* At least one URI is required to use the authorization code grant.
|
||||
*/
|
||||
redirect_uris?: NonEmptyArray<string>;
|
||||
/**
|
||||
* Array of the OAuth 2.0 response types that the client may use.
|
||||
*
|
||||
* This MUST include the code value to use the authorization code grant.
|
||||
*/
|
||||
response_types?: NonEmptyArray<string>;
|
||||
/**
|
||||
* String indicator of the requested authentication method for the token endpoint.
|
||||
*/
|
||||
token_endpoint_auth_method?: string;
|
||||
/**
|
||||
* URL that points to a human-readable terms of service document for the client.
|
||||
*
|
||||
* This URL MUST use the https scheme and SHOULD NOT require authentication to access.
|
||||
* It MUST NOT use a user or password in the authority component of the URI.
|
||||
*
|
||||
* This field can be localized by specifying `tos_uri#$lang`.
|
||||
*/
|
||||
tos_uri?: string;
|
||||
} & {
|
||||
// --- Dynamic Localized Fields (e.g., client_name#es-ES) ---
|
||||
[K in `${LocalizableKeys}#${string}`]?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The OAuth 2.0 grant types that are defined for Matrix in https://spec.matrix.org/v1.17/client-server-api/#grant-types
|
||||
*/
|
||||
export enum OAuthGrantType {
|
||||
/**
|
||||
* As per RFC 6749 section 4.1, the authorization code grant lets the client obtain an access token through a browser redirect.
|
||||
*
|
||||
* See https://spec.matrix.org/v1.18/client-server-api/#authorization-code-grant
|
||||
*/
|
||||
AuthorizationCode = "authorization_code",
|
||||
/**
|
||||
* As per RFC 6749 section 6, the refresh token grant lets the client exchange a refresh token for an access token.
|
||||
*
|
||||
* https://spec.matrix.org/v1.18/client-server-api/#refresh-token-grant
|
||||
*/
|
||||
RefreshToken = "refresh_token",
|
||||
/**
|
||||
* As per RFC 8628, the device authorization grant lets clients on devices with limited input capabilities obtain
|
||||
* an access token by having the user complete authorization on a separate device with a web browser.
|
||||
*
|
||||
* See https://spec.matrix.org/v1.18/client-server-api/#device-authorization-grant
|
||||
*/
|
||||
DeviceAuthorization = "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that URIs have a common base,
|
||||
* as per https://spec.matrix.org/v1.18/client-server-api/#redirect-uri-validation
|
||||
*/
|
||||
export function urlHasCommonBase(base: URL, urlStr?: string): boolean {
|
||||
if (!urlStr) return false;
|
||||
const url = new URL(urlStr);
|
||||
if (url.protocol !== base.protocol) return false;
|
||||
if (url.hostname !== base.hostname && !url.hostname.endsWith(`.${base.hostname}`)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from dynamic registration
|
||||
*/
|
||||
type RegistrationResponse = {
|
||||
client_id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate the given response matches the format expected for a {@link RegistrationResponse}
|
||||
* @param response - the response to validate
|
||||
* @throws if the response does not match the expected format
|
||||
*/
|
||||
export function validateRegistrationResponse(response: unknown): response is RegistrationResponse {
|
||||
return isRecord(response) && hasRequiredStringProperty(response, "client_id");
|
||||
}
|
||||
84
node_modules/matrix-js-sdk/src/oauth/tokenRefresher.ts
generated
vendored
Normal file
84
node_modules/matrix-js-sdk/src/oauth/tokenRefresher.ts
generated
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
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 { type AccessTokens, HTTPError, type TokenRefreshFunction, TokenRefreshLogoutError } from "../http-api/index.ts";
|
||||
import { OAuth2HTTPError } from "./error.ts";
|
||||
import { type OAuth2 } from "./index.ts";
|
||||
|
||||
/**
|
||||
* Class responsible for refreshing OAuth2 access tokens
|
||||
*/
|
||||
export class TokenRefresher {
|
||||
private inflightRefreshRequest?: Promise<AccessTokens>;
|
||||
|
||||
public constructor(
|
||||
private readonly auth: OAuth2,
|
||||
private readonly onRefresh: (tokens: AccessTokens) => Promise<void>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Attempt token refresh using given refresh token
|
||||
* @param refreshToken - refresh token to use in request with token issuer
|
||||
* @returns tokens - Promise that resolves with new access and refresh tokens
|
||||
* @throws when token refresh fails
|
||||
*/
|
||||
public tokenRefreshFunction: TokenRefreshFunction = async (refreshToken: string): Promise<AccessTokens> => {
|
||||
if (!this.inflightRefreshRequest) {
|
||||
this.inflightRefreshRequest = this.getNewTokens(refreshToken);
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await this.inflightRefreshRequest;
|
||||
return tokens;
|
||||
} catch (e) {
|
||||
// If we encounter a 40x error then signal that it should cause a logout by upgrading it to a TokenRefreshLogoutError
|
||||
if (e instanceof HTTPError && this.shouldLogoutOnError(e)) {
|
||||
throw new TokenRefreshLogoutError(e);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
this.inflightRefreshRequest = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
private shouldLogoutOnError(error: HTTPError): boolean {
|
||||
// Treat as logout as per https://spec.matrix.org/v1.18/client-server-api/#refresh-token-grant
|
||||
// after making sure it is an RFC 6749 section 5.2 error response
|
||||
return (
|
||||
error instanceof OAuth2HTTPError &&
|
||||
typeof error.httpStatus === "number" &&
|
||||
error.httpStatus < 500 &&
|
||||
error.httpStatus >= 400
|
||||
);
|
||||
}
|
||||
|
||||
private async getNewTokens(refreshToken: string): Promise<AccessTokens> {
|
||||
const requestStart = Date.now();
|
||||
|
||||
const response = await this.auth.performRefreshTokenGrant(refreshToken);
|
||||
|
||||
const tokens = {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
// We use the request start time to calculate the expiry time as we don't know when the server received our request
|
||||
expiry: response.expires_in ? new Date(requestStart + response.expires_in * 1000) : undefined,
|
||||
} satisfies AccessTokens;
|
||||
|
||||
await this.onRefresh(tokens);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user