Initial easyauth work
Signed-off-by: Alex Crome <afscrome@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
---
|
||||
id: azure-easy-auth
|
||||
title: Azure EasyAuth Provider
|
||||
sidebar_label: Azure EasyAuth
|
||||
description: Adding Azure's EasyAuth Proxy as an authentication provider in Backstage
|
||||
---
|
||||
|
||||
## Backstage Changes
|
||||
|
||||
Add the following into your `app-config.yaml` or `app-config.production.yaml` file
|
||||
|
||||
Add a `providerFactories` entry to the router in
|
||||
`packages/backend/src/plugins/auth.ts`.
|
||||
|
||||
```ts
|
||||
import { providers } from '@backstage/plugin-auth-backend';
|
||||
|
||||
export default async function createPlugin(
|
||||
env: PluginEnvironment,
|
||||
): Promise<Router> {
|
||||
const authProviderFactories = {
|
||||
'azure-easyAuth': providers.easyAuth.create({
|
||||
signIn: {
|
||||
resolver: async (info, ctx) => {
|
||||
const {
|
||||
fullProfile: { id },
|
||||
} = info.result;
|
||||
|
||||
if (!id) {
|
||||
throw new Error('User profile contained no id');
|
||||
}
|
||||
|
||||
return await ctx.signInWithCatalogUser({
|
||||
annotations: {
|
||||
'graph.microsoft.com/user-id': id,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return await createRouter({
|
||||
logger: env.logger,
|
||||
config: env.config,
|
||||
database: env.database,
|
||||
discovery: env.discovery,
|
||||
tokenManager: env.tokenManager,
|
||||
providerFactories: authProviderFactories,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Now the backend is ready to serve auth requests on the
|
||||
`/api/auth/azure-easyAuth/refresh` endpoint. All that's left is to update the frontend
|
||||
sign-in mechanism to poll that endpoint through the IAP, on the user's behalf.
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
It is recommended to use the `ProxiedSignInPage` for this provider, which is
|
||||
installed in `packages/app/src/App.tsx` like this:
|
||||
|
||||
```diff
|
||||
+import { ProxiedSignInPage } from '@backstage/core-components';
|
||||
|
||||
const app = createApp({
|
||||
components: {
|
||||
+ SignInPage: props => <ProxiedSignInPage {...props} provider="azure-easyAuth" />,
|
||||
```
|
||||
|
||||
See the [Sign-In with Proxy Providers](../index.md#sign-in-with-proxy-providers) section for more information.
|
||||
|
||||
## Azure Configuration
|
||||
|
||||
How to configure azure depends on the service you're enable AAD auth on the app service.
|
||||
|
||||
### Azure App Services
|
||||
|
||||
To use EasyAuth with App Services, turn on Active Directory authentication
|
||||
You must also enable the token store.
|
||||
|
||||
The following example shows how to do this via a bicep template:
|
||||
|
||||
```bicep
|
||||
resource webApp 'Microsoft.Web/sites@2022-03-01' existing = {
|
||||
name: 'MY-WEBAPP-NAME'
|
||||
|
||||
resource authConfig 'config' = {
|
||||
name: 'authsettingsV2'
|
||||
properties: {
|
||||
globalValidation: {
|
||||
redirectToProvider: 'AzureActiveDirectory'
|
||||
requireAuthentication: true
|
||||
unauthenticatedClientAction: 'RedirectToLoginPage'
|
||||
}
|
||||
login: {
|
||||
tokenStore: {
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
platform: {
|
||||
enabled: true
|
||||
}
|
||||
identityProviders: {
|
||||
azureActiveDirectory: {
|
||||
enabled: true
|
||||
login: {
|
||||
loginParameters: [ 'domain_hint=MYCOMPANY.COM' ]
|
||||
}
|
||||
registration: {
|
||||
clientId: 'CLIENT-ID'
|
||||
clientSecretSettingName: 'CLIENT-SECRET-NAME'
|
||||
openIdIssuer: 'https://sts.windows.net/${tenant().tenantId}/v2.0'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -154,6 +154,7 @@ nav:
|
||||
- Included providers:
|
||||
- Auth0: 'auth/auth0/provider.md'
|
||||
- Azure: 'auth/microsoft/provider.md'
|
||||
- Azure EasyAuth: 'auth/microsoft/azure-easyauth.md'
|
||||
- GitHub: 'auth/github/provider.md'
|
||||
- GitLab: 'auth/gitlab/provider.md'
|
||||
- Google: 'auth/google/provider.md'
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { easyAuth } from './provider';
|
||||
export type { EasyAuthResponse } from './provider';
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 { AuthHandler, AuthResolverContext } from '../types';
|
||||
import { makeProfileInfo } from '../../lib/passport';
|
||||
import {
|
||||
easyAuth,
|
||||
ACCESS_TOKEN_HEADER,
|
||||
EasyAuthAuthProvider,
|
||||
EasyAuthResult,
|
||||
ID_TOKEN_HEADER,
|
||||
} from './provider';
|
||||
import { Request, Response } from 'express';
|
||||
import { SignJWT, JWTPayload, errors as JoseErrors } from 'jose';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const jwtSecret = randomBytes(48);
|
||||
|
||||
async function buildJwt(claims: JWTPayload) {
|
||||
return await new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.sign(jwtSecret);
|
||||
}
|
||||
|
||||
const backstageIdentityTokenClaims = {
|
||||
sub: 'user:default/alice',
|
||||
ent: ['user:default/alice'],
|
||||
};
|
||||
|
||||
describe('EasyAuthAuthProvider', () => {
|
||||
const authHandler: AuthHandler<EasyAuthResult> = async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
});
|
||||
const resolverContext: AuthResolverContext = {} as AuthResolverContext;
|
||||
async function signInResolver() {
|
||||
return {
|
||||
id: 'user.name',
|
||||
token: await buildJwt(backstageIdentityTokenClaims),
|
||||
};
|
||||
}
|
||||
|
||||
const provider = new EasyAuthAuthProvider({
|
||||
authHandler,
|
||||
signInResolver,
|
||||
resolverContext,
|
||||
});
|
||||
|
||||
function mockRequest(headers?: Record<string, string>) {
|
||||
return {
|
||||
header: (name: string) => headers?.[name],
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
describe('should succeed when', () => {
|
||||
it('id_token is valid and identity is resolved successfully', async () => {
|
||||
const claims = {
|
||||
ver: '2.0',
|
||||
oid: 'c43063d4-0650-4f3e-ba6b-307473d24dfd',
|
||||
name: 'Alice Bob',
|
||||
email: 'alice@bob.com',
|
||||
preferred_username: 'Another name',
|
||||
};
|
||||
const response = {
|
||||
end: jest.fn(),
|
||||
header: () => jest.fn(),
|
||||
json: jest.fn(),
|
||||
status: jest.fn(),
|
||||
} as unknown as Response;
|
||||
|
||||
const request = mockRequest({
|
||||
[ID_TOKEN_HEADER]: await buildJwt(claims),
|
||||
});
|
||||
await provider.refresh(request, response);
|
||||
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
backstageIdentity: {
|
||||
id: 'user.name',
|
||||
token: await buildJwt(backstageIdentityTokenClaims),
|
||||
identity: {
|
||||
ownershipEntityRefs: ['user:default/alice'],
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/alice',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
displayName: claims.name,
|
||||
email: claims.email,
|
||||
picture: undefined,
|
||||
},
|
||||
providerInfo: {
|
||||
accessToken: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('valid id_token and access_token provided', async () => {
|
||||
const claims = {
|
||||
ver: '2.0',
|
||||
oid: 'c43063d4-0650-4f3e-ba6b-307473d24dfd',
|
||||
name: 'Alice Bob',
|
||||
email: 'alice@bob.com',
|
||||
preferred_username: 'Another name',
|
||||
};
|
||||
const response = {
|
||||
end: jest.fn(),
|
||||
header: () => jest.fn(),
|
||||
json: jest.fn(),
|
||||
status: jest.fn(),
|
||||
} as unknown as Response;
|
||||
|
||||
const request = mockRequest({
|
||||
[ID_TOKEN_HEADER]: await buildJwt(claims),
|
||||
[ACCESS_TOKEN_HEADER]: 'ACCESS_TOKEN',
|
||||
});
|
||||
await provider.refresh(request, response);
|
||||
|
||||
expect(response.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerInfo: {
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should fail when', () => {
|
||||
const response = {} as Response;
|
||||
|
||||
it('Access token is missing', async () => {
|
||||
const request = mockRequest();
|
||||
|
||||
await expect(provider.refresh(request, response)).rejects.toThrow(
|
||||
'Missing x-ms-token-aad-id-token header',
|
||||
);
|
||||
});
|
||||
|
||||
it('id token is invalid', async () => {
|
||||
const request = mockRequest({
|
||||
[ID_TOKEN_HEADER]: 'not-a-jwt',
|
||||
});
|
||||
|
||||
await expect(provider.refresh(request, response)).rejects.toThrow(
|
||||
JoseErrors.JWTInvalid,
|
||||
);
|
||||
});
|
||||
|
||||
it('id token is v1', async () => {
|
||||
const request = mockRequest({
|
||||
[ID_TOKEN_HEADER]: await buildJwt({ ver: '1.0' }),
|
||||
});
|
||||
|
||||
await expect(provider.refresh(request, response)).rejects.toThrow(
|
||||
'id_token is not version 2.0',
|
||||
);
|
||||
});
|
||||
|
||||
it('SignInResolver rejects', async () => {
|
||||
const request = mockRequest({
|
||||
[ID_TOKEN_HEADER]: await buildJwt({ ver: '2.0' }),
|
||||
});
|
||||
|
||||
const rejectProvider = new EasyAuthAuthProvider({
|
||||
authHandler,
|
||||
signInResolver: async () => {
|
||||
throw new Error('REJECTED!!');
|
||||
},
|
||||
resolverContext,
|
||||
});
|
||||
|
||||
await expect(rejectProvider.refresh(request, response)).rejects.toThrow(
|
||||
'REJECTED!!',
|
||||
);
|
||||
});
|
||||
|
||||
it('AuthHanlder rejects', async () => {
|
||||
const request = mockRequest({
|
||||
[ID_TOKEN_HEADER]: await buildJwt({ ver: '2.0' }),
|
||||
});
|
||||
|
||||
const rejectProvider = new EasyAuthAuthProvider({
|
||||
authHandler: async () => {
|
||||
throw new Error('OBJECTION!!');
|
||||
},
|
||||
signInResolver,
|
||||
resolverContext,
|
||||
});
|
||||
|
||||
await expect(rejectProvider.refresh(request, response)).rejects.toThrow(
|
||||
'OBJECTION!!',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('easyAuth factory', () => {
|
||||
const env = process.env;
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
process.env = { ...env };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = env;
|
||||
});
|
||||
|
||||
it('should fail when run outside of Azure App Services', async () => {
|
||||
const factory = easyAuth.create({
|
||||
signIn: {
|
||||
resolver: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => factory({} as any)).toThrow(
|
||||
'Backstage is not running on Azure App Services',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail when Azure App Services Auth is not enabled', async () => {
|
||||
process.env.WEBSITE_SKU = 'Standard';
|
||||
process.env.WEBSITE_AUTH_ENABLED = 'False';
|
||||
|
||||
const factory = easyAuth.create({
|
||||
signIn: {
|
||||
resolver: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => factory({} as any)).toThrow(
|
||||
'Azure App Services does not have authentication enabled',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail when Azure App Services Auth is not AAD', async () => {
|
||||
process.env.WEBSITE_SKU = 'Standard';
|
||||
process.env.WEBSITE_AUTH_ENABLED = 'True';
|
||||
process.env.WEBSITE_AUTH_DEFAULT_PROVIDER = 'Facebook';
|
||||
|
||||
const factory = easyAuth.create({
|
||||
signIn: {
|
||||
resolver: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => factory({} as any)).toThrow(
|
||||
'Authentication provider is not Azure Active Directory',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail when Token Store not enabled', async () => {
|
||||
process.env.WEBSITE_SKU = 'Standard';
|
||||
process.env.WEBSITE_AUTH_ENABLED = 'True';
|
||||
process.env.WEBSITE_AUTH_DEFAULT_PROVIDER = 'AzureActiveDirectory';
|
||||
process.env.WEBSITE_AUTH_TOKEN_STORE = 'False';
|
||||
|
||||
const factory = easyAuth.create({
|
||||
signIn: {
|
||||
resolver: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => factory({} as any)).toThrow('Token Store is not enabled');
|
||||
});
|
||||
|
||||
it('should return EasyAuthAuthProvider when running in Azure App Services with AAD Auth', async () => {
|
||||
process.env.WEBSITE_SKU = 'Standard';
|
||||
process.env.WEBSITE_AUTH_ENABLED = 'True';
|
||||
process.env.WEBSITE_AUTH_DEFAULT_PROVIDER = 'AzureActiveDirectory';
|
||||
process.env.WEBSITE_AUTH_TOKEN_STORE = 'True';
|
||||
|
||||
const factory = easyAuth.create({
|
||||
signIn: {
|
||||
resolver: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(factory({} as any)).toBeInstanceOf(EasyAuthAuthProvider);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* 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 {
|
||||
AuthHandler,
|
||||
AuthProviderRouteHandlers,
|
||||
AuthResolverContext,
|
||||
AuthResponse,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import { Request, Response } from 'express';
|
||||
import { makeProfileInfo } from '../../lib/passport';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
|
||||
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
|
||||
import { Profile } from 'passport';
|
||||
import { decodeJwt } from 'jose';
|
||||
|
||||
export const ID_TOKEN_HEADER = 'x-ms-token-aad-id-token';
|
||||
export const ACCESS_TOKEN_HEADER = 'x-ms-token-aad-access-token';
|
||||
|
||||
type Options = {
|
||||
authHandler: AuthHandler<EasyAuthResult>;
|
||||
signInResolver: SignInResolver<EasyAuthResult>;
|
||||
resolverContext: AuthResolverContext;
|
||||
};
|
||||
|
||||
export type EasyAuthResult = {
|
||||
fullProfile: Profile;
|
||||
accessToken?: string;
|
||||
};
|
||||
|
||||
export type EasyAuthResponse = AuthResponse<{}>;
|
||||
|
||||
export class EasyAuthAuthProvider implements AuthProviderRouteHandlers {
|
||||
private readonly resolverContext: AuthResolverContext;
|
||||
private readonly authHandler: AuthHandler<EasyAuthResult>;
|
||||
private readonly signInResolver: SignInResolver<EasyAuthResult>;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.authHandler = options.authHandler;
|
||||
this.signInResolver = options.signInResolver;
|
||||
this.resolverContext = options.resolverContext;
|
||||
}
|
||||
|
||||
frameHandler(): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
async refresh(req: Request, res: Response): Promise<void> {
|
||||
const result = await this.getResult(req);
|
||||
const response = await this.handleResult(result);
|
||||
res.json(response);
|
||||
}
|
||||
|
||||
start(): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
private async getResult(req: Request): Promise<EasyAuthResult> {
|
||||
const idToken = req.header(ID_TOKEN_HEADER);
|
||||
const accessToken = req.header(ACCESS_TOKEN_HEADER);
|
||||
if (idToken === undefined) {
|
||||
throw new AuthenticationError(`Missing ${ID_TOKEN_HEADER} header`);
|
||||
}
|
||||
|
||||
return {
|
||||
fullProfile: this.idTokenToProfile(idToken),
|
||||
accessToken: accessToken,
|
||||
};
|
||||
}
|
||||
|
||||
private idTokenToProfile(idToken: string) {
|
||||
const claims = decodeJwt(idToken);
|
||||
|
||||
if (claims.ver !== '2.0') {
|
||||
throw new Error('id_token is not version 2.0 ');
|
||||
}
|
||||
|
||||
return {
|
||||
id: claims.oid,
|
||||
displayName: claims.name,
|
||||
provider: 'easyauth',
|
||||
emails: [{ value: claims.email }],
|
||||
username: claims.preferred_username,
|
||||
} as Profile;
|
||||
}
|
||||
|
||||
private async handleResult(
|
||||
result: EasyAuthResult,
|
||||
): Promise<EasyAuthResponse> {
|
||||
const { profile } = await this.authHandler(result, this.resolverContext);
|
||||
|
||||
const backstageIdentity = await this.signInResolver(
|
||||
{
|
||||
result,
|
||||
profile,
|
||||
},
|
||||
this.resolverContext,
|
||||
);
|
||||
|
||||
return {
|
||||
providerInfo: {
|
||||
accessToken: result.accessToken,
|
||||
},
|
||||
backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity),
|
||||
profile,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth provider integration for Azure EasyAuth
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const easyAuth = createAuthProviderIntegration({
|
||||
create(options?: {
|
||||
/**
|
||||
* The profile transformation function used to verify and convert the auth response
|
||||
* into the profile that will be presented to the user.
|
||||
*/
|
||||
authHandler?: AuthHandler<EasyAuthResult>;
|
||||
|
||||
/**
|
||||
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
|
||||
*/
|
||||
signIn: {
|
||||
/**
|
||||
* Maps an auth result to a Backstage identity for the user.
|
||||
*/
|
||||
resolver: SignInResolver<EasyAuthResult>;
|
||||
};
|
||||
}) {
|
||||
return ({ resolverContext }) => {
|
||||
validateAppServiceConfiguration(process.env);
|
||||
|
||||
if (options?.signIn.resolver === undefined) {
|
||||
throw new Error(
|
||||
'SignInResolver is required to use this authentication provider',
|
||||
);
|
||||
}
|
||||
|
||||
const authHandler =
|
||||
options.authHandler ??
|
||||
(async ({ fullProfile }) => ({
|
||||
profile: makeProfileInfo(fullProfile),
|
||||
}));
|
||||
|
||||
return new EasyAuthAuthProvider({
|
||||
signInResolver: options.signIn.resolver,
|
||||
authHandler,
|
||||
resolverContext,
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function validateAppServiceConfiguration(env: NodeJS.ProcessEnv) {
|
||||
// Based on https://github.com/AzureAD/microsoft-identity-web/blob/f7403779d1a91f4a3fec0ed0993bd82f50f299e1/src/Microsoft.Identity.Web/AppServicesAuth/AppServicesAuthenticationInformation.cs#L38-L59
|
||||
//
|
||||
// It's critical to validate we're really running in a correctly configured Azure App Services,
|
||||
// As we rely on App Services to manage & validate the ID and Access Token headers
|
||||
// Without that, this users can be trivially impersonated.
|
||||
if (env.WEBSITE_SKU === undefined) {
|
||||
throw new Error('Backstage is not running on Azure App Services');
|
||||
}
|
||||
if (!isEqualCaseInsensitive(env.WEBSITE_AUTH_ENABLED, 'True')) {
|
||||
throw new Error('Azure App Services does not have authentication enabled');
|
||||
}
|
||||
if (
|
||||
!isEqualCaseInsensitive(
|
||||
env.WEBSITE_AUTH_DEFAULT_PROVIDER,
|
||||
'AzureActiveDirectory',
|
||||
)
|
||||
) {
|
||||
throw new Error('Authentication provider is not Azure Active Directory');
|
||||
}
|
||||
if (!isEqualCaseInsensitive(process.env.WEBSITE_AUTH_TOKEN_STORE, 'True')) {
|
||||
throw new Error('Token Store is not enabled');
|
||||
}
|
||||
}
|
||||
|
||||
function isEqualCaseInsensitive(left: string | undefined, right: string) {
|
||||
return (
|
||||
left !== undefined &&
|
||||
right.localeCompare(left, undefined, { sensitivity: 'base' }) === 0
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user