Merge pull request #19280 from backstage/rugvip/auth-migration
auth-backend: migrate to new backend system + new authenticators pattern
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-gcp-iap-provider': minor
|
||||
---
|
||||
|
||||
New module for `@backstage/plugin-auth-backend` that adds a GCP IAP auth provider.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Deprecated several exports that are now available from `@backstage/plugin-auth-node` instead.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
'@backstage/plugin-auth-node': minor
|
||||
---
|
||||
|
||||
Introduced a new system for building auth providers for `@backstage/plugin-auth-backend`, which both increases the amount of code re-use across providers, and also works better with the new backend system.
|
||||
|
||||
Many existing types have been moved from `@backstage/plugin-auth-backend` in order to avoid a direct dependency on the plugin from modules.
|
||||
|
||||
Auth provider integrations are now primarily implemented through a pattern of creating "authenticators", which are in turn specific to each kind of integrations. Initially there are two types: `createOAuthAuthenticator` and `createProxyAuthenticator`. These come paired with functions that let you create the corresponding route handlers, `createOAuthRouteHandlers` and `createProxyAuthRouteHandlers`, as well as provider factories, `createOAuthProviderFactory` and `createProxyAuthProviderFactory`. This new authenticator pattern allows the sign-in logic to be separated from the auth integration logic, allowing it to be completely re-used across all providers of the same kind.
|
||||
|
||||
The new provider factories also implement a new declarative way to configure sign-in resolvers, rather than configuration through code. Sign-in resolvers can now be configured through the `resolvers` configuration key, where the first resolver that provides an identity will be used, for example:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
providers:
|
||||
google:
|
||||
development:
|
||||
clientId: ...
|
||||
clientSecret: ...
|
||||
signIn:
|
||||
resolvers:
|
||||
- resolver: emailMatchingUserEntityAnnotation
|
||||
- resolver: emailLocalPartMatchingUserEntityName
|
||||
```
|
||||
|
||||
These configurable resolvers are created with a new `createSignInResolverFactory` function, which creates a sign-in resolver factory, optionally with an options schema that will be used both when configuring the sign-in resolver through configuration and code.
|
||||
|
||||
The internal helpers from `@backstage/plugin-auth-backend` that were used to implement auth providers using passport strategies have now also been made available as public API, through `PassportHelpers` and `PassportOAuthAuthenticatorHelper`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-google-provider': minor
|
||||
---
|
||||
|
||||
New module for `@backstage/plugin-auth-backend` that adds a Google auth provider.
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,5 @@
|
||||
# Auth Backend Module - GCP IAP Provider
|
||||
|
||||
## Links
|
||||
|
||||
- [The Backstage homepage](https://backstage.io)
|
||||
@@ -0,0 +1,46 @@
|
||||
## API Report File for "@backstage/plugin-auth-backend-module-gcp-iap-provider"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { JsonPrimitive } from '@backstage/types';
|
||||
import { ProxyAuthenticator } from '@backstage/plugin-auth-node';
|
||||
import { SignInResolverFactory } from '@backstage/plugin-auth-node';
|
||||
|
||||
// @public (undocumented)
|
||||
export const authModuleGcpIapProvider: () => BackendFeature;
|
||||
|
||||
// @public (undocumented)
|
||||
export const gcpIapAuthenticator: ProxyAuthenticator<
|
||||
{
|
||||
jwtHeader: string;
|
||||
tokenValidator: (token: string) => Promise<GcpIapTokenInfo>;
|
||||
},
|
||||
{
|
||||
iapToken: GcpIapTokenInfo;
|
||||
}
|
||||
>;
|
||||
|
||||
// @public
|
||||
export type GcpIapResult = {
|
||||
iapToken: GcpIapTokenInfo;
|
||||
};
|
||||
|
||||
// @public
|
||||
export namespace gcpIapSignInResolvers {
|
||||
const emailMatchingUserEntityAnnotation: SignInResolverFactory<
|
||||
GcpIapResult,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type GcpIapTokenInfo = {
|
||||
sub: string;
|
||||
email: string;
|
||||
[key: string]: JsonPrimitive;
|
||||
};
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface Config {
|
||||
auth?: {
|
||||
providers?: {
|
||||
/**
|
||||
* Configuration for the Google Cloud Platform Identity-Aware Proxy (IAP) auth provider.
|
||||
*/
|
||||
gcpIap?: {
|
||||
[authEnv: string]: {
|
||||
/**
|
||||
* The audience to use when validating incoming JWT tokens.
|
||||
* See https://backstage.io/docs/auth/google/gcp-iap-auth
|
||||
*/
|
||||
audience: string;
|
||||
|
||||
/**
|
||||
* The name of the header to read the JWT token from, defaults to `'x-goog-iap-jwt-assertion'`.
|
||||
*/
|
||||
jwtHeader?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@backstage/plugin-auth-backend-module-gcp-iap-provider",
|
||||
"description": "A GCP IAP auth provider module for the Backstage auth backend",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "backend-plugin-module"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/auth-backend-module-gcp-iap-provider"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "backstage-cli package start",
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"clean": "backstage-cli package clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"google-auth-library": "^8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"express": "^4.18.2",
|
||||
"msw": "^1.0.0",
|
||||
"supertest": "^6.1.3"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
],
|
||||
"configSchema": "config.d.ts"
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 { mockServices } from '@backstage/backend-test-utils';
|
||||
import { Request } from 'express';
|
||||
import { gcpIapAuthenticator } from './authenticator';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
jest.mock('./helpers', () => ({
|
||||
createTokenValidator() {
|
||||
return async () => ({ sub: 's', email: 'e' });
|
||||
},
|
||||
}));
|
||||
|
||||
describe('GcpIapProvider', () => {
|
||||
it('should find default JWT header', async () => {
|
||||
const ctx = await gcpIapAuthenticator.initialize({
|
||||
config: mockServices.rootConfig({ data: { audience: 'my-audience' } }),
|
||||
});
|
||||
await expect(
|
||||
gcpIapAuthenticator.authenticate(
|
||||
{
|
||||
req: {
|
||||
header(name: string) {
|
||||
return name === 'x-goog-iap-jwt-assertion'
|
||||
? 'my-token'
|
||||
: undefined;
|
||||
},
|
||||
} as Request,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).resolves.toEqual({ result: { iapToken: { sub: 's', email: 'e' } } });
|
||||
});
|
||||
|
||||
it('should find custom JWT header', async () => {
|
||||
const jwtHeader = 'x-custom-header';
|
||||
const ctx = await gcpIapAuthenticator.initialize({
|
||||
config: mockServices.rootConfig({
|
||||
data: { audience: 'my-audience', jwtHeader },
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
gcpIapAuthenticator.authenticate(
|
||||
{
|
||||
req: {
|
||||
header(name: string) {
|
||||
return name === jwtHeader ? 'my-token' : undefined;
|
||||
},
|
||||
} as Request,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).resolves.toEqual({ result: { iapToken: { sub: 's', email: 'e' } } });
|
||||
});
|
||||
|
||||
it('should throw if header is missing', async () => {
|
||||
const ctx = await gcpIapAuthenticator.initialize({
|
||||
config: mockServices.rootConfig({
|
||||
data: { audience: 'my-audience' },
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
gcpIapAuthenticator.authenticate(
|
||||
{
|
||||
req: {
|
||||
header(_name: string) {
|
||||
return undefined;
|
||||
},
|
||||
} as Request,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow('Missing Google IAP header');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2023 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 { AuthenticationError } from '@backstage/errors';
|
||||
import { createProxyAuthenticator } from '@backstage/plugin-auth-node';
|
||||
import { createTokenValidator } from './helpers';
|
||||
import { GcpIapResult } from './types';
|
||||
|
||||
/**
|
||||
* The header name used by the IAP.
|
||||
*/
|
||||
const DEFAULT_IAP_JWT_HEADER = 'x-goog-iap-jwt-assertion';
|
||||
|
||||
/** @public */
|
||||
export const gcpIapAuthenticator = createProxyAuthenticator({
|
||||
defaultProfileTransform: async (result: GcpIapResult) => {
|
||||
return { profile: { email: result.iapToken.email } };
|
||||
},
|
||||
async initialize({ config }) {
|
||||
const audience = config.getString('audience');
|
||||
const jwtHeader =
|
||||
config.getOptionalString('jwtHeader') ?? DEFAULT_IAP_JWT_HEADER;
|
||||
|
||||
const tokenValidator = createTokenValidator(audience);
|
||||
|
||||
return { jwtHeader, tokenValidator };
|
||||
},
|
||||
async authenticate({ req }, { jwtHeader, tokenValidator }) {
|
||||
const token = req.header(jwtHeader);
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw new AuthenticationError('Missing Google IAP header');
|
||||
}
|
||||
|
||||
const iapToken = await tokenValidator(token);
|
||||
|
||||
return { result: { iapToken } };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { createTokenValidator } from './helpers';
|
||||
|
||||
const mockJwt = 'a.b.c';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('createTokenValidator', () => {
|
||||
it('runs the happy path', async () => {
|
||||
const mockClient = {
|
||||
getIapPublicKeys: async () => ({ pubkeys: '' }),
|
||||
verifySignedJwtWithCertsAsync: async () => ({
|
||||
getPayload: () => ({ sub: 's', email: 'e@mail.com' }),
|
||||
}),
|
||||
};
|
||||
const validator = createTokenValidator(
|
||||
'a',
|
||||
mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
await expect(validator(mockJwt)).resolves.toMatchObject({
|
||||
sub: 's',
|
||||
email: 'e@mail.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if listing keys fail', async () => {
|
||||
const mockClient = {
|
||||
getIapPublicKeys: async () => {
|
||||
throw new Error('NOPE');
|
||||
},
|
||||
};
|
||||
const validator = createTokenValidator(
|
||||
'a',
|
||||
mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
await expect(validator(mockJwt)).rejects.toThrow(
|
||||
'Unable to list Google IAP token verification keys, Error: NOPE',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if the verifying signature fails', async () => {
|
||||
const mockClient = {
|
||||
getIapPublicKeys: async () => ({ pubkeys: '' }),
|
||||
verifySignedJwtWithCertsAsync: async () => {
|
||||
throw new Error('NOPE');
|
||||
},
|
||||
};
|
||||
const validator = createTokenValidator(
|
||||
'a',
|
||||
mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
await expect(validator(mockJwt)).rejects.toThrow(
|
||||
'Google IAP token verification failed, Error: NOPE',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty payload', async () => {
|
||||
const mockClient = {
|
||||
getIapPublicKeys: async () => ({ pubkeys: '' }),
|
||||
verifySignedJwtWithCertsAsync: async () => ({
|
||||
getPayload: () => undefined,
|
||||
}),
|
||||
};
|
||||
const validator = createTokenValidator(
|
||||
'a',
|
||||
mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
await expect(validator(mockJwt)).rejects.toThrow(
|
||||
'Google IAP token verification failed, token had no payload',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects payload without subject', async () => {
|
||||
const mockClient = {
|
||||
getIapPublicKeys: async () => ({ pubkeys: '' }),
|
||||
verifySignedJwtWithCertsAsync: async () => ({
|
||||
getPayload: () => ({ email: 'e@mail.com' }),
|
||||
}),
|
||||
};
|
||||
const validator = createTokenValidator(
|
||||
'a',
|
||||
mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
await expect(validator(mockJwt)).rejects.toThrow(
|
||||
'Google IAP token payload is missing subject claim',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects payload without email', async () => {
|
||||
const mockClient = {
|
||||
getIapPublicKeys: async () => ({ pubkeys: '' }),
|
||||
verifySignedJwtWithCertsAsync: async () => ({
|
||||
getPayload: () => ({ sub: 's' }),
|
||||
}),
|
||||
};
|
||||
const validator = createTokenValidator(
|
||||
'a',
|
||||
mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
await expect(validator(mockJwt)).rejects.toThrow(
|
||||
'Google IAP token payload is missing email claim',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { GcpIapTokenInfo } from './types';
|
||||
|
||||
export function createTokenValidator(
|
||||
audience: string,
|
||||
providedClient?: OAuth2Client,
|
||||
): (token: string) => Promise<GcpIapTokenInfo> {
|
||||
const client = providedClient ?? new OAuth2Client();
|
||||
|
||||
return async function tokenValidator(token) {
|
||||
// TODO(freben): Rate limit the public key reads. It may be sensible to
|
||||
// cache these for some reasonable time rather than asking for the public
|
||||
// keys on every single sign-in. But since the rate of events here is so
|
||||
// slow, I decided to keep it simple for now.
|
||||
const response = await client.getIapPublicKeys().catch(error => {
|
||||
throw new AuthenticationError(
|
||||
`Unable to list Google IAP token verification keys, ${error}`,
|
||||
);
|
||||
});
|
||||
const ticket = await client
|
||||
.verifySignedJwtWithCertsAsync(token, response.pubkeys, audience, [
|
||||
'https://cloud.google.com/iap',
|
||||
])
|
||||
.catch(error => {
|
||||
throw new AuthenticationError(
|
||||
`Google IAP token verification failed, ${error}`,
|
||||
);
|
||||
});
|
||||
|
||||
const payload = ticket.getPayload();
|
||||
if (!payload) {
|
||||
throw new AuthenticationError(
|
||||
'Google IAP token verification failed, token had no payload',
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload.sub) {
|
||||
throw new AuthenticationError(
|
||||
'Google IAP token payload is missing subject claim',
|
||||
);
|
||||
}
|
||||
if (!payload.email) {
|
||||
throw new AuthenticationError(
|
||||
'Google IAP token payload is missing email claim',
|
||||
);
|
||||
}
|
||||
|
||||
return payload as unknown as GcpIapTokenInfo;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2023 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 { gcpIapAuthenticator } from './authenticator';
|
||||
export { authModuleGcpIapProvider } from './module';
|
||||
export { gcpIapSignInResolvers } from './resolvers';
|
||||
export { type GcpIapResult, type GcpIapTokenInfo } from './types';
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2023 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 { createBackendModule } from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
authProvidersExtensionPoint,
|
||||
commonSignInResolvers,
|
||||
createProxyAuthProviderFactory,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { gcpIapAuthenticator } from './authenticator';
|
||||
import { gcpIapSignInResolvers } from './resolvers';
|
||||
|
||||
/** @public */
|
||||
export const authModuleGcpIapProvider = createBackendModule({
|
||||
pluginId: 'auth',
|
||||
moduleId: 'gcpIapProvider',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {
|
||||
providers: authProvidersExtensionPoint,
|
||||
},
|
||||
async init({ providers }) {
|
||||
providers.registerProvider({
|
||||
providerId: 'gcpIap',
|
||||
factory: createProxyAuthProviderFactory({
|
||||
authenticator: gcpIapAuthenticator,
|
||||
signInResolverFactories: {
|
||||
...gcpIapSignInResolvers,
|
||||
...commonSignInResolvers,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
createSignInResolverFactory,
|
||||
SignInInfo,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { GcpIapResult } from './types';
|
||||
|
||||
/**
|
||||
* Available sign-in resolvers for the Google auth provider.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export namespace gcpIapSignInResolvers {
|
||||
/**
|
||||
* Looks up the user by matching their email to the `google.com/email` annotation.
|
||||
*/
|
||||
export const emailMatchingUserEntityAnnotation = createSignInResolverFactory({
|
||||
create() {
|
||||
return async (info: SignInInfo<GcpIapResult>, ctx) => {
|
||||
const email = info.result.iapToken.email;
|
||||
|
||||
if (!email) {
|
||||
throw new Error('Google IAP sign-in result is missing email');
|
||||
}
|
||||
|
||||
return ctx.signInWithCatalogUser({
|
||||
annotations: {
|
||||
'google.com/email': email,
|
||||
},
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2023 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 { JsonPrimitive } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* The data extracted from an IAP token.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type GcpIapTokenInfo = {
|
||||
/**
|
||||
* The unique, stable identifier for the user.
|
||||
*/
|
||||
sub: string;
|
||||
/**
|
||||
* User email address.
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* Other fields.
|
||||
*/
|
||||
[key: string]: JsonPrimitive;
|
||||
};
|
||||
|
||||
/**
|
||||
* The result of the initial auth challenge. This is the input to the auth
|
||||
* callbacks.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type GcpIapResult = {
|
||||
/**
|
||||
* The data extracted from the IAP token header.
|
||||
*/
|
||||
iapToken: GcpIapTokenInfo;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,5 @@
|
||||
# Auth Backend Module - Google Provider
|
||||
|
||||
## Links
|
||||
|
||||
- [The Backstage homepage](https://backstage.io)
|
||||
@@ -0,0 +1,31 @@
|
||||
## API Report File for "@backstage/plugin-auth-backend-module-google-provider"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { OAuthAuthenticator } from '@backstage/plugin-auth-node';
|
||||
import { OAuthAuthenticatorResult } from '@backstage/plugin-auth-node';
|
||||
import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node';
|
||||
import { PassportProfile } from '@backstage/plugin-auth-node';
|
||||
import { SignInResolverFactory } from '@backstage/plugin-auth-node';
|
||||
|
||||
// @public (undocumented)
|
||||
export const authModuleGoogleProvider: () => BackendFeature;
|
||||
|
||||
// @public (undocumented)
|
||||
export const googleAuthenticator: OAuthAuthenticator<
|
||||
PassportOAuthAuthenticatorHelper,
|
||||
PassportProfile
|
||||
>;
|
||||
|
||||
// @public
|
||||
export namespace googleSignInResolvers {
|
||||
const emailMatchingUserEntityAnnotation: SignInResolverFactory<
|
||||
OAuthAuthenticatorResult<PassportProfile>,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface Config {
|
||||
/** Configuration options for the auth plugin */
|
||||
auth?: {
|
||||
providers?: {
|
||||
google?: {
|
||||
[authEnv: string]: {
|
||||
clientId: string;
|
||||
/**
|
||||
* @visibility secret
|
||||
*/
|
||||
clientSecret: string;
|
||||
callbackUrl?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@backstage/plugin-auth-backend-module-google-provider",
|
||||
"description": "A Google auth provider module for the Backstage auth backend",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "backend-plugin-module"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "plugins/auth-backend-module-google-provider"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"scripts": {
|
||||
"start": "backstage-cli package start",
|
||||
"build": "backstage-cli package build",
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test",
|
||||
"prepack": "backstage-cli package prepack",
|
||||
"postpack": "backstage-cli package postpack",
|
||||
"clean": "backstage-cli package clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"google-auth-library": "^8.0.0",
|
||||
"passport-google-oauth20": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@types/passport-google-oauth20": "^2.0.3",
|
||||
"msw": "^1.0.0",
|
||||
"supertest": "^6.1.3"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"config.d.ts"
|
||||
],
|
||||
"configSchema": "config.d.ts"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
PassportOAuthAuthenticatorHelper,
|
||||
PassportOAuthDoneCallback,
|
||||
PassportProfile,
|
||||
createOAuthAuthenticator,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||
|
||||
/** @public */
|
||||
export const googleAuthenticator = createOAuthAuthenticator({
|
||||
defaultProfileTransform:
|
||||
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
|
||||
initialize({ callbackUrl, config }) {
|
||||
const clientId = config.getString('clientId');
|
||||
const clientSecret = config.getString('clientSecret');
|
||||
|
||||
return PassportOAuthAuthenticatorHelper.from(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: clientId,
|
||||
clientSecret: clientSecret,
|
||||
callbackURL: callbackUrl,
|
||||
passReqToCallback: false,
|
||||
},
|
||||
(
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: any,
|
||||
fullProfile: PassportProfile,
|
||||
done: PassportOAuthDoneCallback,
|
||||
) => {
|
||||
done(
|
||||
undefined,
|
||||
{
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
},
|
||||
{
|
||||
refreshToken,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
async start(input, helper) {
|
||||
return helper.start(input, {
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
});
|
||||
},
|
||||
|
||||
async authenticate(input, helper) {
|
||||
return helper.authenticate(input);
|
||||
},
|
||||
|
||||
async refresh(input, helper) {
|
||||
return helper.refresh(input);
|
||||
},
|
||||
|
||||
async logout(input) {
|
||||
if (input.refreshToken) {
|
||||
const oauthClient = new OAuth2Client();
|
||||
await oauthClient.revokeToken(input.refreshToken);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2023 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 { googleAuthenticator } from './authenticator';
|
||||
export { authModuleGoogleProvider } from './module';
|
||||
export { googleSignInResolvers } from './resolvers';
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2023 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 { createBackendModule } from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
authProvidersExtensionPoint,
|
||||
commonSignInResolvers,
|
||||
createOAuthProviderFactory,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { googleAuthenticator } from './authenticator';
|
||||
import { googleSignInResolvers } from './resolvers';
|
||||
|
||||
/** @public */
|
||||
export const authModuleGoogleProvider = createBackendModule({
|
||||
pluginId: 'auth',
|
||||
moduleId: 'googleProvider',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {
|
||||
providers: authProvidersExtensionPoint,
|
||||
},
|
||||
async init({ providers }) {
|
||||
providers.registerProvider({
|
||||
providerId: 'google',
|
||||
factory: createOAuthProviderFactory({
|
||||
authenticator: googleAuthenticator,
|
||||
signInResolverFactories: {
|
||||
...googleSignInResolvers,
|
||||
...commonSignInResolvers,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
createSignInResolverFactory,
|
||||
OAuthAuthenticatorResult,
|
||||
PassportProfile,
|
||||
SignInInfo,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
|
||||
/**
|
||||
* Available sign-in resolvers for the Google auth provider.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export namespace googleSignInResolvers {
|
||||
/**
|
||||
* Looks up the user by matching their email to the `google.com/email` annotation.
|
||||
*/
|
||||
export const emailMatchingUserEntityAnnotation = createSignInResolverFactory({
|
||||
create() {
|
||||
return async (
|
||||
info: SignInInfo<OAuthAuthenticatorResult<PassportProfile>>,
|
||||
ctx,
|
||||
) => {
|
||||
const { profile } = info;
|
||||
|
||||
if (!profile.email) {
|
||||
throw new Error('Google profile contained no email');
|
||||
}
|
||||
|
||||
return ctx.signInWithCatalogUser({
|
||||
annotations: {
|
||||
'google.com/email': profile.email,
|
||||
},
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
+101
-192
@@ -5,98 +5,69 @@
|
||||
```ts
|
||||
/// <reference types="node" />
|
||||
|
||||
import { BackstageIdentityResponse } from '@backstage/plugin-auth-node';
|
||||
import { AuthProviderConfig as AuthProviderConfig_2 } from '@backstage/plugin-auth-node';
|
||||
import { AuthProviderFactory as AuthProviderFactory_2 } from '@backstage/plugin-auth-node';
|
||||
import { AuthProviderRouteHandlers as AuthProviderRouteHandlers_2 } from '@backstage/plugin-auth-node';
|
||||
import { AuthResolverCatalogUserQuery as AuthResolverCatalogUserQuery_2 } from '@backstage/plugin-auth-node';
|
||||
import { AuthResolverContext as AuthResolverContext_2 } from '@backstage/plugin-auth-node';
|
||||
import { BackstageSignInResult } from '@backstage/plugin-auth-node';
|
||||
import { CacheService } from '@backstage/backend-plugin-api';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { ClientAuthResponse } from '@backstage/plugin-auth-node';
|
||||
import { Config } from '@backstage/config';
|
||||
import { CookieConfigurer as CookieConfigurer_2 } from '@backstage/plugin-auth-node';
|
||||
import { decodeOAuthState } from '@backstage/plugin-auth-node';
|
||||
import { encodeOAuthState } from '@backstage/plugin-auth-node';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import express from 'express';
|
||||
import { GetEntitiesRequest } from '@backstage/catalog-client';
|
||||
import { GcpIapResult as GcpIapResult_2 } from '@backstage/plugin-auth-backend-module-gcp-iap-provider';
|
||||
import { GcpIapTokenInfo as GcpIapTokenInfo_2 } from '@backstage/plugin-auth-backend-module-gcp-iap-provider';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { Logger } from 'winston';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { OAuthEnvironmentHandler as OAuthEnvironmentHandler_2 } from '@backstage/plugin-auth-node';
|
||||
import { OAuthState as OAuthState_2 } from '@backstage/plugin-auth-node';
|
||||
import { PluginDatabaseManager } from '@backstage/backend-common';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { prepareBackstageIdentityResponse as prepareBackstageIdentityResponse_2 } from '@backstage/plugin-auth-node';
|
||||
import { Profile } from 'passport';
|
||||
import { ProfileInfo as ProfileInfo_2 } from '@backstage/plugin-auth-node';
|
||||
import { SignInInfo as SignInInfo_2 } from '@backstage/plugin-auth-node';
|
||||
import { SignInResolver as SignInResolver_2 } from '@backstage/plugin-auth-node';
|
||||
import { TokenManager } from '@backstage/backend-common';
|
||||
import { TokenParams as TokenParams_2 } from '@backstage/plugin-auth-node';
|
||||
import { TokenSet } from 'openid-client';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
import { UserinfoResponse } from 'openid-client';
|
||||
import { WebMessageResponse as WebMessageResponse_2 } from '@backstage/plugin-auth-node';
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type AuthHandler<TAuthResult> = (
|
||||
input: TAuthResult,
|
||||
context: AuthResolverContext,
|
||||
) => Promise<AuthHandlerResult>;
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type AuthHandlerResult = {
|
||||
profile: ProfileInfo;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type AuthProviderConfig = {
|
||||
baseUrl: string;
|
||||
appUrl: string;
|
||||
isOriginAllowed: (origin: string) => boolean;
|
||||
cookieConfigurer?: CookieConfigurer;
|
||||
};
|
||||
// @public @deprecated (undocumented)
|
||||
export type AuthProviderConfig = AuthProviderConfig_2;
|
||||
|
||||
// @public (undocumented)
|
||||
export type AuthProviderFactory = (options: {
|
||||
providerId: string;
|
||||
globalConfig: AuthProviderConfig;
|
||||
config: Config;
|
||||
logger: Logger;
|
||||
resolverContext: AuthResolverContext;
|
||||
}) => AuthProviderRouteHandlers;
|
||||
// @public @deprecated (undocumented)
|
||||
export type AuthProviderFactory = AuthProviderFactory_2;
|
||||
|
||||
// @public
|
||||
export interface AuthProviderRouteHandlers {
|
||||
frameHandler(req: express.Request, res: express.Response): Promise<void>;
|
||||
logout?(req: express.Request, res: express.Response): Promise<void>;
|
||||
refresh?(req: express.Request, res: express.Response): Promise<void>;
|
||||
start(req: express.Request, res: express.Response): Promise<void>;
|
||||
}
|
||||
// @public @deprecated (undocumented)
|
||||
export type AuthProviderRouteHandlers = AuthProviderRouteHandlers_2;
|
||||
|
||||
// @public
|
||||
export type AuthResolverCatalogUserQuery =
|
||||
| {
|
||||
entityRef:
|
||||
| string
|
||||
| {
|
||||
kind?: string;
|
||||
namespace?: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
annotations: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
filter: Exclude<GetEntitiesRequest['filter'], undefined>;
|
||||
};
|
||||
// @public @deprecated (undocumented)
|
||||
export type AuthResolverCatalogUserQuery = AuthResolverCatalogUserQuery_2;
|
||||
|
||||
// @public
|
||||
export type AuthResolverContext = {
|
||||
issueToken(params: TokenParams): Promise<{
|
||||
token: string;
|
||||
}>;
|
||||
findCatalogUser(query: AuthResolverCatalogUserQuery): Promise<{
|
||||
entity: Entity;
|
||||
}>;
|
||||
signInWithCatalogUser(
|
||||
query: AuthResolverCatalogUserQuery,
|
||||
): Promise<BackstageSignInResult>;
|
||||
};
|
||||
// @public @deprecated (undocumented)
|
||||
export type AuthResolverContext = AuthResolverContext_2;
|
||||
|
||||
// @public (undocumented)
|
||||
export type AuthResponse<ProviderInfo> = {
|
||||
providerInfo: ProviderInfo;
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity?: BackstageIdentityResponse;
|
||||
};
|
||||
// @public @deprecated (undocumented)
|
||||
export type AuthResponse<TProviderInfo> = ClientAuthResponse<TProviderInfo>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type AwsAlbResult = {
|
||||
@@ -151,7 +122,7 @@ export class CatalogIdentityClient {
|
||||
findUser(query: { annotations: Record<string, string> }): Promise<UserEntity>;
|
||||
resolveCatalogMembership(query: {
|
||||
entityRefs: string[];
|
||||
logger?: Logger;
|
||||
logger?: LoggerService;
|
||||
}): Promise<string[]>;
|
||||
}
|
||||
|
||||
@@ -191,18 +162,8 @@ export type CloudflareAccessResult = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type CookieConfigurer = (ctx: {
|
||||
providerId: string;
|
||||
baseUrl: string;
|
||||
callbackUrl: string;
|
||||
appOrigin: string;
|
||||
}) => {
|
||||
domain: string;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
sameSite?: 'none' | 'lax' | 'strict';
|
||||
};
|
||||
// @public @deprecated (undocumented)
|
||||
export type CookieConfigurer = CookieConfigurer_2;
|
||||
|
||||
// @public
|
||||
export function createAuthProviderIntegration<
|
||||
@@ -235,23 +196,17 @@ export type EasyAuthResult = {
|
||||
accessToken?: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const encodeState: (state: OAuthState) => string;
|
||||
// @public @deprecated (undocumented)
|
||||
export const encodeState: typeof encodeOAuthState;
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export const ensuresXRequestedWith: (req: express.Request) => boolean;
|
||||
|
||||
// @public
|
||||
export type GcpIapResult = {
|
||||
iapToken: GcpIapTokenInfo;
|
||||
};
|
||||
// @public @deprecated
|
||||
export type GcpIapResult = GcpIapResult_2;
|
||||
|
||||
// @public
|
||||
export type GcpIapTokenInfo = {
|
||||
sub: string;
|
||||
email: string;
|
||||
[key: string]: JsonValue;
|
||||
};
|
||||
// @public @deprecated
|
||||
export type GcpIapTokenInfo = GcpIapTokenInfo_2;
|
||||
|
||||
// @public
|
||||
export function getDefaultOwnershipEntityRefs(entity: Entity): string[];
|
||||
@@ -276,7 +231,7 @@ export type OAuth2ProxyResult<JWTPayload = {}> = {
|
||||
getHeader(name: string): string | undefined;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export class OAuthAdapter implements AuthProviderRouteHandlers {
|
||||
constructor(handlers: OAuthHandlers, options: OAuthAdapterOptions);
|
||||
// (undocumented)
|
||||
@@ -298,7 +253,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
|
||||
start(req: express.Request, res: express.Response): Promise<void>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type OAuthAdapterOptions = {
|
||||
providerId: string;
|
||||
persistScopes?: boolean;
|
||||
@@ -309,25 +264,10 @@ export type OAuthAdapterOptions = {
|
||||
callbackUrl: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers {
|
||||
constructor(handlers: Map<string, AuthProviderRouteHandlers>);
|
||||
// (undocumented)
|
||||
frameHandler(req: express.Request, res: express.Response): Promise<void>;
|
||||
// (undocumented)
|
||||
logout(req: express.Request, res: express.Response): Promise<void>;
|
||||
// (undocumented)
|
||||
static mapConfig(
|
||||
config: Config,
|
||||
factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers,
|
||||
): OAuthEnvironmentHandler;
|
||||
// (undocumented)
|
||||
refresh(req: express.Request, res: express.Response): Promise<void>;
|
||||
// (undocumented)
|
||||
start(req: express.Request, res: express.Response): Promise<void>;
|
||||
}
|
||||
// @public @deprecated (undocumented)
|
||||
export const OAuthEnvironmentHandler: typeof OAuthEnvironmentHandler_2;
|
||||
|
||||
// @public
|
||||
// @public @deprecated (undocumented)
|
||||
export interface OAuthHandlers {
|
||||
handler(req: express.Request): Promise<{
|
||||
response: OAuthResponse;
|
||||
@@ -341,12 +281,12 @@ export interface OAuthHandlers {
|
||||
start(req: OAuthStartRequest): Promise<OAuthStartResponse>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type OAuthLogoutRequest = express.Request<{}> & {
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type OAuthProviderInfo = {
|
||||
accessToken: string;
|
||||
idToken?: string;
|
||||
@@ -354,59 +294,53 @@ export type OAuthProviderInfo = {
|
||||
scope: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type OAuthProviderOptions = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
callbackUrl: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type OAuthRefreshRequest = express.Request<{}> & {
|
||||
scope: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
// @public @deprecated (undocumented)
|
||||
export type OAuthResponse = {
|
||||
profile: ProfileInfo;
|
||||
providerInfo: OAuthProviderInfo;
|
||||
backstageIdentity?: BackstageSignInResult;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type OAuthResult = {
|
||||
fullProfile: Profile;
|
||||
params: {
|
||||
id_token?: string;
|
||||
scope: string;
|
||||
token_type?: string;
|
||||
expires_in: number;
|
||||
};
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type OAuthStartRequest = express.Request<{}> & {
|
||||
scope: string;
|
||||
state: OAuthState;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type OAuthStartResponse = {
|
||||
url: string;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type OAuthState = {
|
||||
nonce: string;
|
||||
env: string;
|
||||
origin?: string;
|
||||
scope?: string;
|
||||
redirectUrl?: string;
|
||||
flow?: string;
|
||||
};
|
||||
// @public @deprecated (undocumented)
|
||||
export type OAuthState = OAuthState_2;
|
||||
|
||||
// @public
|
||||
export type OidcAuthResult = {
|
||||
@@ -414,24 +348,18 @@ export type OidcAuthResult = {
|
||||
userinfo: UserinfoResponse;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export const postMessageResponse: (
|
||||
res: express.Response,
|
||||
appOrigin: string,
|
||||
response: WebMessageResponse,
|
||||
) => void;
|
||||
|
||||
// @public
|
||||
export function prepareBackstageIdentityResponse(
|
||||
result: BackstageSignInResult,
|
||||
): BackstageIdentityResponse;
|
||||
// @public @deprecated (undocumented)
|
||||
export const prepareBackstageIdentityResponse: typeof prepareBackstageIdentityResponse_2;
|
||||
|
||||
// @public
|
||||
export type ProfileInfo = {
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
picture?: string;
|
||||
};
|
||||
// @public @deprecated (undocumented)
|
||||
export type ProfileInfo = ProfileInfo_2;
|
||||
|
||||
// @public (undocumented)
|
||||
export type ProviderFactories = {
|
||||
@@ -452,7 +380,7 @@ export const providers: Readonly<{
|
||||
| undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: never;
|
||||
}>;
|
||||
auth0: Readonly<{
|
||||
@@ -467,7 +395,7 @@ export const providers: Readonly<{
|
||||
| undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: never;
|
||||
}>;
|
||||
awsAlb: Readonly<{
|
||||
@@ -480,7 +408,7 @@ export const providers: Readonly<{
|
||||
};
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: never;
|
||||
}>;
|
||||
bitbucket: Readonly<{
|
||||
@@ -495,7 +423,7 @@ export const providers: Readonly<{
|
||||
| undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: Readonly<{
|
||||
usernameMatchingUserEntityAnnotation(): SignInResolver<OAuthResult>;
|
||||
userIdMatchingUserEntityAnnotation(): SignInResolver<OAuthResult>;
|
||||
@@ -513,7 +441,7 @@ export const providers: Readonly<{
|
||||
| undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: Readonly<{
|
||||
emailMatchingUserEntityProfileEmail: () => SignInResolver<BitbucketServerOAuthResult>;
|
||||
}>;
|
||||
@@ -525,18 +453,18 @@ export const providers: Readonly<{
|
||||
resolver: SignInResolver<CloudflareAccessResult>;
|
||||
};
|
||||
cache?: CacheService | undefined;
|
||||
}) => AuthProviderFactory;
|
||||
}) => AuthProviderFactory_2;
|
||||
resolvers: Readonly<{
|
||||
emailMatchingUserEntityProfileEmail: () => SignInResolver<unknown>;
|
||||
}>;
|
||||
}>;
|
||||
gcpIap: Readonly<{
|
||||
create: (options: {
|
||||
authHandler?: AuthHandler<GcpIapResult> | undefined;
|
||||
authHandler?: AuthHandler<GcpIapResult_2> | undefined;
|
||||
signIn: {
|
||||
resolver: SignInResolver<GcpIapResult>;
|
||||
resolver: SignInResolver<GcpIapResult_2>;
|
||||
};
|
||||
}) => AuthProviderFactory;
|
||||
}) => AuthProviderFactory_2;
|
||||
resolvers: never;
|
||||
}>;
|
||||
github: Readonly<{
|
||||
@@ -552,7 +480,7 @@ export const providers: Readonly<{
|
||||
stateEncoder?: StateEncoder | undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: Readonly<{
|
||||
usernameMatchingUserEntityName: () => SignInResolver<GithubOAuthResult>;
|
||||
}>;
|
||||
@@ -569,7 +497,7 @@ export const providers: Readonly<{
|
||||
| undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: never;
|
||||
}>;
|
||||
google: Readonly<{
|
||||
@@ -584,11 +512,11 @@ export const providers: Readonly<{
|
||||
| undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: Readonly<{
|
||||
emailLocalPartMatchingUserEntityName: () => SignInResolver<unknown>;
|
||||
emailMatchingUserEntityProfileEmail: () => SignInResolver<unknown>;
|
||||
emailMatchingUserEntityAnnotation(): SignInResolver<OAuthResult>;
|
||||
emailMatchingUserEntityProfileEmail: () => SignInResolver_2<OAuthResult>;
|
||||
emailLocalPartMatchingUserEntityName: () => SignInResolver_2<OAuthResult>;
|
||||
emailMatchingUserEntityAnnotation: () => SignInResolver_2<OAuthResult>;
|
||||
}>;
|
||||
}>;
|
||||
microsoft: Readonly<{
|
||||
@@ -603,7 +531,7 @@ export const providers: Readonly<{
|
||||
| undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: Readonly<{
|
||||
emailLocalPartMatchingUserEntityName: () => SignInResolver<unknown>;
|
||||
emailMatchingUserEntityProfileEmail: () => SignInResolver<unknown>;
|
||||
@@ -622,7 +550,7 @@ export const providers: Readonly<{
|
||||
| undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: never;
|
||||
}>;
|
||||
oauth2Proxy: Readonly<{
|
||||
@@ -631,7 +559,7 @@ export const providers: Readonly<{
|
||||
signIn: {
|
||||
resolver: SignInResolver<OAuth2ProxyResult<unknown>>;
|
||||
};
|
||||
}) => AuthProviderFactory;
|
||||
}) => AuthProviderFactory_2;
|
||||
resolvers: never;
|
||||
}>;
|
||||
oidc: Readonly<{
|
||||
@@ -646,7 +574,7 @@ export const providers: Readonly<{
|
||||
| undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: Readonly<{
|
||||
emailLocalPartMatchingUserEntityName: () => SignInResolver<unknown>;
|
||||
emailMatchingUserEntityProfileEmail: () => SignInResolver<unknown>;
|
||||
@@ -664,7 +592,7 @@ export const providers: Readonly<{
|
||||
| undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: Readonly<{
|
||||
emailLocalPartMatchingUserEntityName: () => SignInResolver<unknown>;
|
||||
emailMatchingUserEntityProfileEmail: () => SignInResolver<unknown>;
|
||||
@@ -683,7 +611,7 @@ export const providers: Readonly<{
|
||||
| undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: never;
|
||||
}>;
|
||||
saml: Readonly<{
|
||||
@@ -698,7 +626,7 @@ export const providers: Readonly<{
|
||||
| undefined;
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: Readonly<{
|
||||
nameIdMatchingUserEntityName(): SignInResolver<SamlAuthResult>;
|
||||
}>;
|
||||
@@ -713,13 +641,13 @@ export const providers: Readonly<{
|
||||
};
|
||||
}
|
||||
| undefined,
|
||||
) => AuthProviderFactory;
|
||||
) => AuthProviderFactory_2;
|
||||
resolvers: never;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const readState: (stateString: string) => OAuthState;
|
||||
// @public @deprecated (undocumented)
|
||||
export const readState: typeof decodeOAuthState;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface RouterOptions {
|
||||
@@ -732,7 +660,7 @@ export interface RouterOptions {
|
||||
// (undocumented)
|
||||
discovery: PluginEndpointDiscovery;
|
||||
// (undocumented)
|
||||
logger: Logger;
|
||||
logger: LoggerService;
|
||||
// (undocumented)
|
||||
providerFactories?: ProviderFactories;
|
||||
// (undocumented)
|
||||
@@ -746,42 +674,23 @@ export type SamlAuthResult = {
|
||||
fullProfile: any;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type SignInInfo<TAuthResult> = {
|
||||
profile: ProfileInfo;
|
||||
result: TAuthResult;
|
||||
};
|
||||
// @public @deprecated (undocumented)
|
||||
export type SignInInfo<TAuthResult> = SignInInfo_2<TAuthResult>;
|
||||
|
||||
// @public
|
||||
export type SignInResolver<TAuthResult> = (
|
||||
info: SignInInfo<TAuthResult>,
|
||||
context: AuthResolverContext,
|
||||
) => Promise<BackstageSignInResult>;
|
||||
// @public @deprecated (undocumented)
|
||||
export type SignInResolver<TAuthResult> = SignInResolver_2<TAuthResult>;
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type StateEncoder = (req: OAuthStartRequest) => Promise<{
|
||||
encodedState: string;
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export type TokenParams = {
|
||||
claims: {
|
||||
sub: string;
|
||||
ent?: string[];
|
||||
} & Record<string, JsonValue>;
|
||||
};
|
||||
// @public @deprecated (undocumented)
|
||||
export type TokenParams = TokenParams_2;
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export const verifyNonce: (req: express.Request, providerId: string) => void;
|
||||
|
||||
// @public
|
||||
export type WebMessageResponse =
|
||||
| {
|
||||
type: 'authorization_response';
|
||||
response: AuthResponse<unknown>;
|
||||
}
|
||||
| {
|
||||
type: 'authorization_response';
|
||||
error: Error;
|
||||
};
|
||||
// @public @deprecated (undocumented)
|
||||
export type WebMessageResponse = WebMessageResponse_2;
|
||||
```
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<svg host="65bd71144e" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="801px" height="271px" viewBox="-0.5 -0.5 801 271" content="<mxfile><diagram id="m9H7LUwiOP4VaYd3QZaE" name="Page-1">zVnLjpswFP2abCpNBSbP5TSTaboYNWoqtbO04AasGoyMk5B+fU2ww8NklGkbw2Iy+Pp5jo/v9WPkLeP8M8dp9MICoCPkBPnIexoh5LqLqfxXWE6lBTmTeWkJOQlUqcqwJb9BGR1l3ZMAskZBwRgVJG0afZYk4IuGDXPOjs1iO0abvaY4BMOw9TE1rT9IIKLS6o2dyr4GEka657nKiLEuqwxZhAN2rJm81chbcsZE+RXnS6AFeZqWst7zldzLuDgk4pYKqKxwwHSvoG1wlqWMi63gWEB4UuMUJ42ds30SQFHfGXmfjhERsE2xX+Qe5XRLWyRiKlOu/FTNAxeQXx2iewEuFQMsBsFlt46q8OBqspRa3IVKHyvqL2WiGu16OrCa7fDSdsWI/FCkdBM0NvBDIKWgkglLoAtwUeZtuLJJtue+KoWU/AXmIYja1JiscKBYkEOz+S6EquqGEdlxxebsCpm6iXJYqlaLp8swbqIOLaxwN+mgbv6P3N2qjomxfL4+7kVU/MkhEx8LxntfQPPWjKOO5TO90/JxPSsamJoaWFiSgDvpC6E7tgVx3hvEqSWIl/FUEO+Cx5Yqp4Zj2nCWn+TPQe6c+DP2pWfqP7aPW75pbNM1OQb8e6i6I7Jbc02mqN9GCDkRP4vJ/zhRqddazlOudHFOnFTiv7EyscXKrK95t+fNepO2NQeH7ITdrn25rbCLehMqsiXUuRGmzvvnoYWpHqPUwmBIkrMjFL5znGQ7xuPe2emTHnfcxU9+GtYJrL3NsXsEM/eCj74PWbaMwP/VOzdt9XRd7tyNGnN1bUmYfEm+QcaohDU4dh5ci/Sg9x7e7e0fC0M7atm69NF911SDpbt5YcGewgcdu3qXTls5Nt0yMt3yObCvkgPhLIklhDVOAjoAmszrZZs8mb65vEAMcCqGSM7MJjnm7lA/Tpi3rGug6SAIm7bU5JmMIedOjHnvPfD/nfPVkOrO19aJQfc9cOfb3vHd0/vKZPVgWL7TVM+u3uoP</diagram></mxfile>">
|
||||
<defs/>
|
||||
<g>
|
||||
<rect x="40" y="230" width="120" height="40" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 250px; margin-left: 41px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
PassportStrategy
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="100" y="254" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
PassportStrategy
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<path d="M 100 210 L 100 223.63" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 100 228.88 L 96.5 221.88 L 100 223.63 L 103.5 221.88 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<path d="M 240 183.64 L 206.36 185.17" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 201.12 185.4 L 207.95 181.59 L 206.36 185.17 L 208.27 188.58 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<rect x="240" y="160" width="160" height="40" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 180px; margin-left: 241px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
OAuthAuthenticator
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="320" y="184" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
OAuthAuthenticator
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<path d="M 640 100 L 606.37 100" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 601.12 100 L 608.12 96.5 L 606.37 100 L 608.12 103.5 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<path d="M 720 120 L 720 153.63" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 720 158.88 L 716.5 151.88 L 720 153.63 L 723.5 151.88 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<path d="M 640 120 L 606.18 128.46" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 601.08 129.73 L 607.03 124.64 L 606.18 128.46 L 608.72 131.43 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<path d="M 640 80 L 606.18 71.54" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 601.08 70.27 L 608.72 68.57 L 606.18 71.54 L 607.03 75.36 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<rect x="640" y="80" width="160" height="40" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 100px; margin-left: 641px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
ProxyProviderFactory
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="720" y="104" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
ProxyProviderFactory
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<path d="M 400 100 L 433.63 100" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 438.88 100 L 431.88 103.5 L 433.63 100 L 431.88 96.5 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<path d="M 320 120 L 320 153.63" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 320 158.88 L 316.5 151.88 L 320 153.63 L 323.5 151.88 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<path d="M 400 120 L 433.82 128.46" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 438.92 129.73 L 431.28 131.43 L 433.82 128.46 L 432.97 124.64 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<path d="M 400 80 L 433.82 71.54" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 438.92 70.27 L 432.97 75.36 L 433.82 71.54 L 431.28 68.57 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<path d="M 240 88 L 206.3 82.94" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 201.11 82.17 L 208.55 79.74 L 206.3 82.94 L 207.51 86.67 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<path d="M 240 112 L 206.3 117.06" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 201.11 117.83 L 207.51 113.33 L 206.3 117.06 L 208.55 120.26 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<rect x="240" y="80" width="160" height="40" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 100px; margin-left: 241px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
OAuthProviderFactory
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="320" y="104" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
OAuthProviderFactory
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="440" y="80" width="160" height="40" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 100px; margin-left: 441px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
ProfileTransform
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="520" y="104" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
ProfileTransform
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="640" y="160" width="160" height="40" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 180px; margin-left: 641px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
ProxyAuthenticator
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="720" y="184" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
ProxyAuthenticator
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="440" y="130" width="160" height="40" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 150px; margin-left: 441px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
AccessCheck
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="520" y="154" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
AccessCheck
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="440" y="30" width="160" height="40" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 50px; margin-left: 441px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
SignInResolver
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="520" y="54" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
SignInResolver
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<path d="M 320 40 L 320 73.63" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 320 78.88 L 316.5 71.88 L 320 73.63 L 323.5 71.88 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<rect x="240" y="0" width="160" height="40" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 20px; margin-left: 241px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
authModule*Provider
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="320" y="24" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
authModule*Provider
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="40" y="50" width="160" height="40" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 70px; margin-left: 41px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
OAuthEnvironmentHandler
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="120" y="74" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
OAuthEnvironmentHandler
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="40" y="110" width="160" height="40" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 130px; margin-left: 41px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
OAuthAdapter
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="120" y="134" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
OAuthAdapter
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<rect x="0" y="170" width="200" height="40" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 198px; height: 1px; padding-top: 190px; margin-left: 1px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
PassportOAuthAuthenticatorHelper
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="100" y="194" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
PassportOAuthAuthenticatorHelper
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
<path d="M 720 40 L 720 73.63" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/>
|
||||
<path d="M 720 78.88 L 716.5 71.88 L 720 73.63 L 723.5 71.88 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/>
|
||||
<rect x="640" y="0" width="160" height="40" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/>
|
||||
<g transform="translate(-0.5 -0.5)">
|
||||
<switch>
|
||||
<foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 158px; height: 1px; padding-top: 20px; margin-left: 641px;">
|
||||
<div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;">
|
||||
<div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">
|
||||
authModule*Provider
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text x="720" y="24" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">
|
||||
authModule*Provider
|
||||
</text>
|
||||
</switch>
|
||||
</g>
|
||||
</g>
|
||||
<switch>
|
||||
<g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/>
|
||||
<a transform="translate(0,-5)" xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank">
|
||||
<text text-anchor="middle" font-size="10px" x="50%" y="100%">
|
||||
Text is not SVG - cannot display
|
||||
</text>
|
||||
</a>
|
||||
</switch>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 23 KiB |
@@ -33,10 +33,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/catalog-client": "workspace:^",
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-gcp-iap-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@davidzemon/passport-okta-oauth": "^0.0.5",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Logger } from 'winston';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
DocumentData,
|
||||
Firestore,
|
||||
@@ -57,7 +57,7 @@ export class FirestoreKeyStore implements KeyStore {
|
||||
|
||||
static async verifyConnection(
|
||||
keyStore: FirestoreKeyStore,
|
||||
logger?: Logger,
|
||||
logger?: LoggerService,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await keyStore.verify();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { pickBy } from 'lodash';
|
||||
import { Logger } from 'winston';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
@@ -26,7 +26,7 @@ import { MemoryKeyStore } from './MemoryKeyStore';
|
||||
import { KeyStore } from './types';
|
||||
|
||||
type Options = {
|
||||
logger: Logger;
|
||||
logger: LoggerService;
|
||||
database: AuthDatabase;
|
||||
};
|
||||
|
||||
|
||||
@@ -18,14 +18,14 @@ import { AuthenticationError } from '@backstage/errors';
|
||||
import { exportJWK, generateKeyPair, importJWK, JWK, SignJWT } from 'jose';
|
||||
import { DateTime } from 'luxon';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Logger } from 'winston';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
|
||||
import { AnyJWK, KeyStore, TokenIssuer, TokenParams } from './types';
|
||||
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
type Options = {
|
||||
logger: Logger;
|
||||
logger: LoggerService;
|
||||
/** Value of the issuer claim in issued tokens */
|
||||
issuer: string;
|
||||
/** Key store used for storing signing keys */
|
||||
@@ -57,7 +57,7 @@ type Options = {
|
||||
*/
|
||||
export class TokenFactory implements TokenIssuer {
|
||||
private readonly issuer: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly logger: LoggerService;
|
||||
private readonly keyStore: KeyStore;
|
||||
private readonly keyDurationSeconds: number;
|
||||
private readonly algorithm: string;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { TokenParams as _TokenParams } from '@backstage/plugin-auth-node';
|
||||
|
||||
/** Represents any form of serializable JWK */
|
||||
export interface AnyJWK extends Record<string, string> {
|
||||
@@ -25,26 +25,10 @@ export interface AnyJWK extends Record<string, string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters used to issue new ID Tokens
|
||||
*
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type TokenParams = {
|
||||
/**
|
||||
* The claims that will be embedded within the token. At a minimum, this should include
|
||||
* the subject claim, `sub`. It is common to also list entity ownership relations in the
|
||||
* `ent` list. Additional claims may also be added at the developer's discretion except
|
||||
* for the following list, which will be overwritten by the TokenIssuer: `iss`, `aud`,
|
||||
* `iat`, and `exp`. The Backstage team also maintains the right add new claims in the future
|
||||
* without listing the change as a "breaking change".
|
||||
*/
|
||||
claims: {
|
||||
/** The token subject, i.e. User ID */
|
||||
sub: string;
|
||||
/** A list of entity references that the user claims ownership through */
|
||||
ent?: string[];
|
||||
} & Record<string, JsonValue>;
|
||||
};
|
||||
export type TokenParams = _TokenParams;
|
||||
|
||||
/**
|
||||
* A TokenIssuer is able to issue verifiable ID Tokens on demand.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Logger } from 'winston';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { ConflictError, NotFoundError } from '@backstage/errors';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import {
|
||||
@@ -78,7 +78,7 @@ export class CatalogIdentityClient {
|
||||
*/
|
||||
async resolveCatalogMembership(query: {
|
||||
entityRefs: string[];
|
||||
logger?: Logger;
|
||||
logger?: LoggerService;
|
||||
}): Promise<string[]> {
|
||||
const { entityRefs, logger } = query;
|
||||
const resolvedEntityRefs = entityRefs
|
||||
|
||||
@@ -24,7 +24,10 @@ export const safelyEncodeURIComponent = (value: string) => {
|
||||
return encodeURIComponent(value).replace(/'/g, '%27');
|
||||
};
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `sendWebMessageResponse` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export const postMessageResponse = (
|
||||
res: express.Response,
|
||||
appOrigin: string,
|
||||
@@ -69,7 +72,10 @@ export const postMessageResponse = (
|
||||
res.end(`<html><body><script>${script}</script></body></html>`);
|
||||
};
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use inline logic to check that the `X-Requested-With` header is set to `'XMLHttpRequest'` instead.
|
||||
*/
|
||||
export const ensuresXRequestedWith = (req: express.Request) => {
|
||||
const requiredHeader = req.header('X-Requested-With');
|
||||
if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
|
||||
|
||||
@@ -14,20 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AuthResponse } from '../../providers/types';
|
||||
import { WebMessageResponse as _WebMessageResponse } from '@backstage/plugin-auth-node';
|
||||
|
||||
/**
|
||||
* Payload sent as a post message after the auth request is complete.
|
||||
* If successful then has a valid payload with Auth information else contains an error.
|
||||
*
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type WebMessageResponse =
|
||||
| {
|
||||
type: 'authorization_response';
|
||||
response: AuthResponse<unknown>;
|
||||
}
|
||||
| {
|
||||
type: 'authorization_response';
|
||||
error: Error;
|
||||
};
|
||||
export type WebMessageResponse = _WebMessageResponse;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2023 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 { AuthResolverContext } from '@backstage/plugin-auth-node';
|
||||
import { AuthHandler } from '../../providers';
|
||||
import { OAuthResult } from '../oauth';
|
||||
import { PassportProfile } from '../passport/types';
|
||||
import { adaptLegacyOAuthHandler } from './adaptLegacyOAuthHandler';
|
||||
|
||||
describe('adaptLegacyOAuthHandler', () => {
|
||||
it('should pass through undefined', () => {
|
||||
expect(adaptLegacyOAuthHandler(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should convert an old auth handler to a new profile transform', () => {
|
||||
const authHandler: AuthHandler<OAuthResult> = jest.fn();
|
||||
const profileTransform = adaptLegacyOAuthHandler(authHandler);
|
||||
|
||||
profileTransform?.(
|
||||
{
|
||||
fullProfile: { id: 'id' } as PassportProfile,
|
||||
session: {
|
||||
accessToken: 'token',
|
||||
expiresInSeconds: 3,
|
||||
scope: 'sco pe',
|
||||
tokenType: 'bear',
|
||||
idToken: 'id-token',
|
||||
refreshToken: 'refresh-token',
|
||||
},
|
||||
},
|
||||
{ ctx: 'ctx' } as unknown as AuthResolverContext,
|
||||
);
|
||||
|
||||
expect(authHandler).toHaveBeenCalledWith(
|
||||
{
|
||||
fullProfile: { id: 'id' },
|
||||
accessToken: 'token',
|
||||
params: {
|
||||
scope: 'sco pe',
|
||||
id_token: 'id-token',
|
||||
expires_in: 3,
|
||||
token_type: 'bear',
|
||||
},
|
||||
},
|
||||
{ ctx: 'ctx' },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
OAuthAuthenticatorResult,
|
||||
ProfileTransform,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { AuthHandler } from '../../providers';
|
||||
import { OAuthResult } from '../oauth';
|
||||
import { PassportProfile } from '../passport/types';
|
||||
|
||||
/** @internal */
|
||||
export function adaptLegacyOAuthHandler(
|
||||
authHandler?: AuthHandler<OAuthResult>,
|
||||
): ProfileTransform<OAuthAuthenticatorResult<PassportProfile>> | undefined {
|
||||
return (
|
||||
authHandler &&
|
||||
(async (result, ctx) =>
|
||||
authHandler(
|
||||
{
|
||||
fullProfile: result.fullProfile,
|
||||
accessToken: result.session.accessToken,
|
||||
params: {
|
||||
scope: result.session.scope,
|
||||
id_token: result.session.idToken,
|
||||
token_type: result.session.tokenType,
|
||||
expires_in: result.session.expiresInSeconds,
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
AuthResolverContext,
|
||||
PassportProfile,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { adaptLegacyOAuthSignInResolver } from './adaptLegacyOAuthSignInResolver';
|
||||
|
||||
describe('adaptLegacyOAuthSignInResolver', () => {
|
||||
it('should pass through undefined', () => {
|
||||
expect(adaptLegacyOAuthSignInResolver(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should convert a legacy resolver to a new one', () => {
|
||||
const legacyResolver = jest.fn();
|
||||
|
||||
const newResolver = adaptLegacyOAuthSignInResolver(legacyResolver);
|
||||
|
||||
newResolver?.(
|
||||
{
|
||||
profile: { email: 'em@i.l' },
|
||||
result: {
|
||||
fullProfile: { id: 'id' } as PassportProfile,
|
||||
session: {
|
||||
accessToken: 'token',
|
||||
expiresInSeconds: 3,
|
||||
scope: 'sco pe',
|
||||
tokenType: 'bear',
|
||||
idToken: 'id-token',
|
||||
refreshToken: 'refresh-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ ctx: 'ctx' } as unknown as AuthResolverContext,
|
||||
);
|
||||
|
||||
expect(legacyResolver).toHaveBeenCalledWith(
|
||||
{
|
||||
profile: { email: 'em@i.l' },
|
||||
result: {
|
||||
fullProfile: { id: 'id' },
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh-token',
|
||||
params: {
|
||||
scope: 'sco pe',
|
||||
id_token: 'id-token',
|
||||
expires_in: 3,
|
||||
token_type: 'bear',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ ctx: 'ctx' },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
OAuthAuthenticatorResult,
|
||||
PassportProfile,
|
||||
SignInResolver,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { OAuthResult } from '../oauth';
|
||||
|
||||
/** @internal */
|
||||
export function adaptLegacyOAuthSignInResolver(
|
||||
signInResolver?: SignInResolver<OAuthResult>,
|
||||
): SignInResolver<OAuthAuthenticatorResult<PassportProfile>> | undefined {
|
||||
return (
|
||||
signInResolver &&
|
||||
(async (input, ctx) =>
|
||||
signInResolver(
|
||||
{
|
||||
profile: input.profile,
|
||||
result: {
|
||||
fullProfile: input.result.fullProfile,
|
||||
accessToken: input.result.session.accessToken,
|
||||
refreshToken: input.result.session.refreshToken,
|
||||
params: {
|
||||
scope: input.result.session.scope,
|
||||
id_token: input.result.session.idToken,
|
||||
token_type: input.result.session.tokenType,
|
||||
expires_in: input.result.session.expiresInSeconds,
|
||||
},
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
AuthResolverContext,
|
||||
PassportProfile,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { adaptOAuthSignInResolverToLegacy } from './adaptOAuthSignInResolverToLegacy';
|
||||
|
||||
describe('adaptOAuthSignInResolverToLegacy', () => {
|
||||
it('should pass through an empty object', () => {
|
||||
const legacyResolvers = adaptOAuthSignInResolverToLegacy({});
|
||||
expect(legacyResolvers).toEqual({});
|
||||
|
||||
// @ts-expect-error
|
||||
legacyResolvers.missing?.();
|
||||
});
|
||||
|
||||
it('should adapt a collection of sign-in resolvers', () => {
|
||||
const resolverA = jest.fn();
|
||||
const resolverB = jest.fn();
|
||||
|
||||
const legacyResolvers = adaptOAuthSignInResolverToLegacy({
|
||||
resolverA,
|
||||
resolverB,
|
||||
});
|
||||
|
||||
const legacyResolverA = legacyResolvers.resolverA();
|
||||
legacyResolverA(
|
||||
{
|
||||
profile: { email: 'em@i.l' },
|
||||
result: {
|
||||
fullProfile: { id: 'id' } as PassportProfile,
|
||||
accessToken: 'token',
|
||||
refreshToken: 'refresh-token',
|
||||
params: {
|
||||
scope: 'sco pe',
|
||||
id_token: 'id-token',
|
||||
expires_in: 3,
|
||||
token_type: 'bear',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ ctx: 'ctx' } as unknown as AuthResolverContext,
|
||||
);
|
||||
|
||||
expect(resolverA).toHaveBeenCalledWith(
|
||||
{
|
||||
profile: { email: 'em@i.l' },
|
||||
result: {
|
||||
fullProfile: { id: 'id' } as PassportProfile,
|
||||
session: {
|
||||
accessToken: 'token',
|
||||
expiresInSeconds: 3,
|
||||
scope: 'sco pe',
|
||||
tokenType: 'bear',
|
||||
idToken: 'id-token',
|
||||
refreshToken: 'refresh-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
{ ctx: 'ctx' },
|
||||
);
|
||||
|
||||
expect(resolverB).not.toHaveBeenCalled();
|
||||
legacyResolvers.resolverB()(
|
||||
{ profile: {}, result: { params: {} } } as any,
|
||||
{} as any,
|
||||
);
|
||||
expect(resolverB).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
OAuthAuthenticatorResult,
|
||||
PassportProfile,
|
||||
SignInResolver,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { OAuthResult } from '../oauth';
|
||||
|
||||
/** @internal */
|
||||
export function adaptOAuthSignInResolverToLegacy<
|
||||
TKeys extends string,
|
||||
>(resolvers: {
|
||||
[key in TKeys]: SignInResolver<OAuthAuthenticatorResult<PassportProfile>>;
|
||||
}): { [key in TKeys]: () => SignInResolver<OAuthResult> } {
|
||||
const legacyResolvers = {} as {
|
||||
[key in TKeys]: () => SignInResolver<OAuthResult>;
|
||||
};
|
||||
for (const name of Object.keys(resolvers) as TKeys[]) {
|
||||
const resolver = resolvers[name];
|
||||
legacyResolvers[name] = () => async (input, ctx) =>
|
||||
resolver(
|
||||
{
|
||||
profile: input.profile,
|
||||
result: {
|
||||
fullProfile: input.result.fullProfile,
|
||||
session: {
|
||||
accessToken: input.result.accessToken,
|
||||
expiresInSeconds: input.result.params.expires_in,
|
||||
scope: input.result.params.scope,
|
||||
idToken: input.result.params.id_token,
|
||||
tokenType: input.result.params.token_type ?? 'bearer',
|
||||
refreshToken: input.result.refreshToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
return legacyResolvers;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2023 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 { adaptLegacyOAuthHandler } from './adaptLegacyOAuthHandler';
|
||||
export { adaptLegacyOAuthSignInResolver } from './adaptLegacyOAuthSignInResolver';
|
||||
export { adaptOAuthSignInResolverToLegacy } from './adaptOAuthSignInResolverToLegacy';
|
||||
@@ -50,7 +50,10 @@ import { prepareBackstageIdentityResponse } from '../../providers/prepareBacksta
|
||||
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
|
||||
export const TEN_MINUTES_MS = 600 * 1000;
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type OAuthAdapterOptions = {
|
||||
providerId: string;
|
||||
persistScopes?: boolean;
|
||||
@@ -61,7 +64,10 @@ export type OAuthAdapterOptions = {
|
||||
callbackUrl: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export class OAuthAdapter implements AuthProviderRouteHandlers {
|
||||
static fromConfig(
|
||||
config: AuthProviderConfig,
|
||||
|
||||
@@ -14,84 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { Config } from '@backstage/config';
|
||||
import { InputError, NotFoundError } from '@backstage/errors';
|
||||
import { readState } from './helpers';
|
||||
import { AuthProviderRouteHandlers } from '../../providers/types';
|
||||
import { OAuthEnvironmentHandler as _OAuthEnvironmentHandler } from '@backstage/plugin-auth-node';
|
||||
|
||||
/** @public */
|
||||
export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers {
|
||||
static mapConfig(
|
||||
config: Config,
|
||||
factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers,
|
||||
) {
|
||||
const envs = config.keys();
|
||||
const handlers = new Map<string, AuthProviderRouteHandlers>();
|
||||
|
||||
for (const env of envs) {
|
||||
const envConfig = config.getConfig(env);
|
||||
const handler = factoryFunc(envConfig);
|
||||
handlers.set(env, handler);
|
||||
}
|
||||
|
||||
return new OAuthEnvironmentHandler(handlers);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly handlers: Map<string, AuthProviderRouteHandlers>,
|
||||
) {}
|
||||
|
||||
async start(req: express.Request, res: express.Response): Promise<void> {
|
||||
const provider = this.getProviderForEnv(req);
|
||||
await provider.start(req, res);
|
||||
}
|
||||
|
||||
async frameHandler(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<void> {
|
||||
const provider = this.getProviderForEnv(req);
|
||||
await provider.frameHandler(req, res);
|
||||
}
|
||||
|
||||
async refresh(req: express.Request, res: express.Response): Promise<void> {
|
||||
const provider = this.getProviderForEnv(req);
|
||||
await provider.refresh?.(req, res);
|
||||
}
|
||||
|
||||
async logout(req: express.Request, res: express.Response): Promise<void> {
|
||||
const provider = this.getProviderForEnv(req);
|
||||
await provider.logout?.(req, res);
|
||||
}
|
||||
|
||||
private getRequestFromEnv(req: express.Request): string | undefined {
|
||||
const reqEnv = req.query.env?.toString();
|
||||
if (reqEnv) {
|
||||
return reqEnv;
|
||||
}
|
||||
const stateParams = req.query.state?.toString();
|
||||
if (!stateParams) {
|
||||
return undefined;
|
||||
}
|
||||
const env = readState(stateParams).env;
|
||||
return env;
|
||||
}
|
||||
|
||||
private getProviderForEnv(req: express.Request): AuthProviderRouteHandlers {
|
||||
const env: string | undefined = this.getRequestFromEnv(req);
|
||||
|
||||
if (!env) {
|
||||
throw new InputError(`Must specify 'env' query to select environment`);
|
||||
}
|
||||
|
||||
const handler = this.handlers.get(env);
|
||||
if (!handler) {
|
||||
throw new NotFoundError(
|
||||
`No configuration available for the '${env}' environment of this provider.`,
|
||||
);
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export const OAuthEnvironmentHandler = _OAuthEnvironmentHandler;
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('OAuthProvider Utils', () => {
|
||||
} as unknown as express.Request;
|
||||
expect(() => {
|
||||
verifyNonce(mockRequest, 'providera');
|
||||
}).toThrow('Invalid state passed via request');
|
||||
}).toThrow('OAuth state is invalid, missing env');
|
||||
});
|
||||
|
||||
it('should throw error if nonce mismatch', () => {
|
||||
|
||||
@@ -16,36 +16,28 @@
|
||||
|
||||
import express from 'express';
|
||||
import { OAuthState } from './types';
|
||||
import pickBy from 'lodash/pickBy';
|
||||
import { CookieConfigurer } from '../../providers/types';
|
||||
import {
|
||||
decodeOAuthState,
|
||||
encodeOAuthState,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
|
||||
/** @public */
|
||||
export const readState = (stateString: string): OAuthState => {
|
||||
const state = Object.fromEntries(
|
||||
new URLSearchParams(Buffer.from(stateString, 'hex').toString('utf-8')),
|
||||
);
|
||||
if (
|
||||
!state.nonce ||
|
||||
!state.env ||
|
||||
state.nonce?.length === 0 ||
|
||||
state.env?.length === 0
|
||||
) {
|
||||
throw Error(`Invalid state passed via request`);
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `decodeOAuthState` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export const readState = decodeOAuthState;
|
||||
|
||||
return state as OAuthState;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `encodeOAuthState` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export const encodeState = encodeOAuthState;
|
||||
|
||||
/** @public */
|
||||
export const encodeState = (state: OAuthState): string => {
|
||||
const stateString = new URLSearchParams(
|
||||
pickBy<string>(state, value => value !== undefined),
|
||||
).toString();
|
||||
|
||||
return Buffer.from(stateString, 'utf-8').toString('hex');
|
||||
};
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use inline logic to make sure the session and state nonce matches instead.
|
||||
*/
|
||||
export const verifyNonce = (req: express.Request, providerId: string) => {
|
||||
const cookieNonce = req.cookies[`${providerId}-nonce`];
|
||||
const state: OAuthState = readState(req.query.state?.toString() ?? '');
|
||||
|
||||
@@ -16,13 +16,17 @@
|
||||
|
||||
import express from 'express';
|
||||
import { Profile as PassportProfile } from 'passport';
|
||||
import { BackstageSignInResult } from '@backstage/plugin-auth-node';
|
||||
import {
|
||||
BackstageSignInResult,
|
||||
OAuthState as _OAuthState,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { OAuthStartResponse, ProfileInfo } from '../../providers/types';
|
||||
|
||||
/**
|
||||
* Common options for passport.js-based OAuth providers
|
||||
*
|
||||
* @public
|
||||
* @deprecated No longer in use
|
||||
*/
|
||||
export type OAuthProviderOptions = {
|
||||
/**
|
||||
@@ -39,12 +43,16 @@ export type OAuthProviderOptions = {
|
||||
callbackUrl: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `OAuthAuthenticatorResult<PassportProfile>` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type OAuthResult = {
|
||||
fullProfile: PassportProfile;
|
||||
params: {
|
||||
id_token?: string;
|
||||
scope: string;
|
||||
token_type?: string;
|
||||
expires_in: number;
|
||||
};
|
||||
accessToken: string;
|
||||
@@ -52,9 +60,8 @@ export type OAuthResult = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The expected response from an OAuth flow.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use `ClientAuthResponse` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type OAuthResponse = {
|
||||
profile: ProfileInfo;
|
||||
@@ -62,7 +69,10 @@ export type OAuthResponse = {
|
||||
backstageIdentity?: BackstageSignInResult;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type OAuthProviderInfo = {
|
||||
/**
|
||||
* An access token issued for the signed in user.
|
||||
@@ -82,41 +92,41 @@ export type OAuthProviderInfo = {
|
||||
scope: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type OAuthState = {
|
||||
/* A type for the serialized value in the `state` parameter of the OAuth authorization flow
|
||||
*/
|
||||
nonce: string;
|
||||
env: string;
|
||||
origin?: string;
|
||||
scope?: string;
|
||||
redirectUrl?: string;
|
||||
flow?: string;
|
||||
};
|
||||
/**
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type OAuthState = _OAuthState;
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type OAuthStartRequest = express.Request<{}> & {
|
||||
scope: string;
|
||||
state: OAuthState;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type OAuthRefreshRequest = express.Request<{}> & {
|
||||
scope: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type OAuthLogoutRequest = express.Request<{}> & {
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Any OAuth provider needs to implement this interface which has provider specific
|
||||
* handlers for different methods to perform authentication, get access tokens,
|
||||
* refresh tokens and perform sign out.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export interface OAuthHandlers {
|
||||
/**
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { ConflictError, InputError, NotFoundError } from '@backstage/errors';
|
||||
import { Logger } from 'winston';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { TokenIssuer, TokenParams } from '../../identity/types';
|
||||
import { AuthResolverContext } from '../../providers';
|
||||
import { AuthResolverCatalogUserQuery } from '../../providers/types';
|
||||
@@ -54,7 +54,7 @@ export function getDefaultOwnershipEntityRefs(entity: Entity) {
|
||||
*/
|
||||
export class CatalogAuthResolverContext implements AuthResolverContext {
|
||||
static create(options: {
|
||||
logger: Logger;
|
||||
logger: LoggerService;
|
||||
catalogApi: CatalogApi;
|
||||
tokenIssuer: TokenIssuer;
|
||||
tokenManager: TokenManager;
|
||||
@@ -73,7 +73,7 @@ export class CatalogAuthResolverContext implements AuthResolverContext {
|
||||
}
|
||||
|
||||
private constructor(
|
||||
public readonly logger: Logger,
|
||||
public readonly logger: LoggerService,
|
||||
public readonly tokenIssuer: TokenIssuer,
|
||||
public readonly catalogIdentityClient: CatalogIdentityClient,
|
||||
private readonly catalogApi: CatalogApi,
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { ConflictError } from '@backstage/errors';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { createTokenValidator, parseRequestToken } from './helpers';
|
||||
|
||||
const validJwt =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzcyI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.T2BNS4G-6RoiFnXc8Q8TiwdWzTpNitY8jcsGM3N3-Yo';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('createTokenValidator', () => {
|
||||
it('runs the happy path', async () => {
|
||||
const mockClient = {
|
||||
getIapPublicKeys: async () => ({ pubkeys: '' }),
|
||||
verifySignedJwtWithCertsAsync: async () => ({
|
||||
getPayload: () => ({ sub: 's', email: 'e@mail.com' }),
|
||||
}),
|
||||
};
|
||||
const validator = createTokenValidator(
|
||||
'a',
|
||||
mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
await expect(validator(validJwt)).resolves.toMatchObject({
|
||||
sub: 's',
|
||||
email: 'e@mail.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the client throws', async () => {
|
||||
const mockClient = {
|
||||
getIapPublicKeys: async () => {
|
||||
throw new TypeError('bam');
|
||||
},
|
||||
};
|
||||
const validator = createTokenValidator(
|
||||
'a',
|
||||
mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
await expect(validator(validJwt)).rejects.toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('rejects empty payload', async () => {
|
||||
const mockClient = {
|
||||
getIapPublicKeys: async () => ({ pubkeys: '' }),
|
||||
verifySignedJwtWithCertsAsync: async () => ({
|
||||
getPayload: () => undefined,
|
||||
}),
|
||||
};
|
||||
const validator = createTokenValidator(
|
||||
'a',
|
||||
mockClient as unknown as OAuth2Client,
|
||||
);
|
||||
await expect(validator(validJwt)).rejects.toMatchObject({
|
||||
name: 'TypeError',
|
||||
message: 'Token had no payload',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRequestToken', () => {
|
||||
it('runs the happy path', async () => {
|
||||
await expect(
|
||||
parseRequestToken(
|
||||
validJwt,
|
||||
async () => ({ sub: 's', email: 'e@mail.com' } as any),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
iapToken: {
|
||||
sub: 's',
|
||||
email: 'e@mail.com',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects bad tokens', async () => {
|
||||
await expect(
|
||||
parseRequestToken(7, undefined as any),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AuthenticationError',
|
||||
message: 'Missing Google IAP header',
|
||||
});
|
||||
await expect(
|
||||
parseRequestToken(undefined, undefined as any),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AuthenticationError',
|
||||
message: 'Missing Google IAP header',
|
||||
});
|
||||
await expect(
|
||||
parseRequestToken('', undefined as any),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AuthenticationError',
|
||||
message: 'Missing Google IAP header',
|
||||
});
|
||||
});
|
||||
|
||||
it('translates validator errors', async () => {
|
||||
await expect(
|
||||
parseRequestToken(validJwt, async () => {
|
||||
throw new ConflictError('Ouch');
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AuthenticationError',
|
||||
message: 'Google IAP token verification failed, ConflictError: Ouch',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects bad token payloads', async () => {
|
||||
await expect(
|
||||
parseRequestToken(validJwt, async () => ({ sub: 'a' } as any)),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AuthenticationError',
|
||||
message: 'Google IAP token payload is missing sub and/or email claim',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { OAuth2Client, TokenPayload } from 'google-auth-library';
|
||||
import { AuthHandler } from '../types';
|
||||
import { GcpIapResult } from './types';
|
||||
|
||||
export function createTokenValidator(
|
||||
audience: string,
|
||||
mockClient?: OAuth2Client,
|
||||
): (token: string) => Promise<TokenPayload> {
|
||||
const client = mockClient ?? new OAuth2Client();
|
||||
|
||||
return async function tokenValidator(token) {
|
||||
// TODO(freben): Rate limit the public key reads. It may be sensible to
|
||||
// cache these for some reasonable time rather than asking for the public
|
||||
// keys on every single sign-in. But since the rate of events here is so
|
||||
// slow, I decided to keep it simple for now.
|
||||
const response = await client.getIapPublicKeys();
|
||||
const ticket = await client.verifySignedJwtWithCertsAsync(
|
||||
token,
|
||||
response.pubkeys,
|
||||
audience,
|
||||
['https://cloud.google.com/iap'],
|
||||
);
|
||||
|
||||
const payload = ticket.getPayload();
|
||||
if (!payload) {
|
||||
throw new TypeError('Token had no payload');
|
||||
}
|
||||
|
||||
return payload;
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseRequestToken(
|
||||
jwtToken: unknown,
|
||||
tokenValidator: (token: string) => Promise<TokenPayload>,
|
||||
): Promise<GcpIapResult> {
|
||||
if (typeof jwtToken !== 'string' || !jwtToken) {
|
||||
throw new AuthenticationError('Missing Google IAP header');
|
||||
}
|
||||
|
||||
let payload: TokenPayload;
|
||||
try {
|
||||
payload = await tokenValidator(jwtToken);
|
||||
} catch (e) {
|
||||
throw new AuthenticationError(`Google IAP token verification failed, ${e}`);
|
||||
}
|
||||
|
||||
if (!payload.sub || !payload.email) {
|
||||
throw new AuthenticationError(
|
||||
'Google IAP token payload is missing sub and/or email claim',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
iapToken: {
|
||||
...payload,
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultAuthHandler: AuthHandler<GcpIapResult> = async ({
|
||||
iapToken,
|
||||
}) => ({ profile: { email: iapToken.email } });
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* 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 express from 'express';
|
||||
import request from 'supertest';
|
||||
import { AuthResolverContext } from '../types';
|
||||
import { GcpIapProvider } from './provider';
|
||||
import { DEFAULT_IAP_JWT_HEADER } from './types';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GcpIapProvider', () => {
|
||||
const authHandler = jest.fn();
|
||||
const signInResolver = jest.fn();
|
||||
const tokenValidator = jest.fn();
|
||||
|
||||
it.each([undefined, 'x-custom-header'])(
|
||||
'runs the happy path',
|
||||
async jwtHeader => {
|
||||
const provider = new GcpIapProvider({
|
||||
authHandler,
|
||||
signInResolver,
|
||||
tokenValidator,
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
jwtHeader: jwtHeader,
|
||||
});
|
||||
|
||||
// { "sub": "user:default/me", "ent": ["group:default/home"] }
|
||||
const backstageToken =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvbWUiLCJlbnQiOlsiZ3JvdXA6ZGVmYXVsdC9ob21lIl19.CbmAKzFErGmtsnpRxyPc7dHv7WEjb5lY6206YCzR_Rc';
|
||||
const iapToken = { sub: 's', email: 'e@mail.com' };
|
||||
|
||||
authHandler.mockResolvedValueOnce({ email: 'e@mail.com' });
|
||||
signInResolver.mockResolvedValueOnce({ token: backstageToken });
|
||||
tokenValidator.mockResolvedValueOnce(iapToken);
|
||||
|
||||
const app = express();
|
||||
app.use('/refresh', provider.refresh.bind(provider));
|
||||
|
||||
const header = jwtHeader || DEFAULT_IAP_JWT_HEADER;
|
||||
const response = await request(app).get('/refresh').set(header, 'token');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.get('content-type')).toBe(
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
expect(response.body).toEqual({
|
||||
backstageIdentity: {
|
||||
token: backstageToken,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: 'user:default/me',
|
||||
ownershipEntityRefs: ['group:default/home'],
|
||||
},
|
||||
},
|
||||
providerInfo: { iapToken },
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -14,70 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { TokenPayload } from 'google-auth-library';
|
||||
import { gcpIapAuthenticator } from '@backstage/plugin-auth-backend-module-gcp-iap-provider';
|
||||
import { createProxyAuthProviderFactory } from '@backstage/plugin-auth-node';
|
||||
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
|
||||
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
|
||||
import {
|
||||
AuthHandler,
|
||||
AuthProviderRouteHandlers,
|
||||
AuthResolverContext,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import {
|
||||
createTokenValidator,
|
||||
defaultAuthHandler,
|
||||
parseRequestToken,
|
||||
} from './helpers';
|
||||
import { GcpIapResponse, GcpIapResult, DEFAULT_IAP_JWT_HEADER } from './types';
|
||||
|
||||
export class GcpIapProvider implements AuthProviderRouteHandlers {
|
||||
private readonly authHandler: AuthHandler<GcpIapResult>;
|
||||
private readonly signInResolver: SignInResolver<GcpIapResult>;
|
||||
private readonly tokenValidator: (token: string) => Promise<TokenPayload>;
|
||||
private readonly resolverContext: AuthResolverContext;
|
||||
private readonly jwtHeader: string;
|
||||
|
||||
constructor(options: {
|
||||
authHandler: AuthHandler<GcpIapResult>;
|
||||
signInResolver: SignInResolver<GcpIapResult>;
|
||||
tokenValidator: (token: string) => Promise<TokenPayload>;
|
||||
resolverContext: AuthResolverContext;
|
||||
jwtHeader?: string;
|
||||
}) {
|
||||
this.authHandler = options.authHandler;
|
||||
this.signInResolver = options.signInResolver;
|
||||
this.tokenValidator = options.tokenValidator;
|
||||
this.resolverContext = options.resolverContext;
|
||||
this.jwtHeader = options?.jwtHeader || DEFAULT_IAP_JWT_HEADER;
|
||||
}
|
||||
|
||||
async start() {}
|
||||
|
||||
async frameHandler() {}
|
||||
|
||||
async refresh(req: express.Request, res: express.Response): Promise<void> {
|
||||
const result = await parseRequestToken(
|
||||
req.header(this.jwtHeader),
|
||||
this.tokenValidator,
|
||||
);
|
||||
|
||||
const { profile } = await this.authHandler(result, this.resolverContext);
|
||||
|
||||
const backstageIdentity = await this.signInResolver(
|
||||
{ profile, result },
|
||||
this.resolverContext,
|
||||
);
|
||||
|
||||
const response: GcpIapResponse = {
|
||||
providerInfo: { iapToken: result.iapToken },
|
||||
profile,
|
||||
backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity),
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
}
|
||||
}
|
||||
import { AuthHandler, SignInResolver } from '../types';
|
||||
import { GcpIapResult } from './types';
|
||||
|
||||
/**
|
||||
* Auth provider integration for Google Identity-Aware Proxy auth
|
||||
@@ -104,21 +45,10 @@ export const gcpIap = createAuthProviderIntegration({
|
||||
resolver: SignInResolver<GcpIapResult>;
|
||||
};
|
||||
}) {
|
||||
return ({ config, resolverContext }) => {
|
||||
const audience = config.getString('audience');
|
||||
const jwtHeader = config.getOptionalString('jwtHeader');
|
||||
|
||||
const authHandler = options.authHandler ?? defaultAuthHandler;
|
||||
const signInResolver = options.signIn.resolver;
|
||||
const tokenValidator = createTokenValidator(audience);
|
||||
|
||||
return new GcpIapProvider({
|
||||
authHandler,
|
||||
signInResolver,
|
||||
tokenValidator,
|
||||
resolverContext,
|
||||
jwtHeader,
|
||||
});
|
||||
};
|
||||
return createProxyAuthProviderFactory({
|
||||
authenticator: gcpIapAuthenticator,
|
||||
profileTransform: options?.authHandler,
|
||||
signInResolver: options?.signIn?.resolver,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,58 +14,24 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { AuthResponse } from '../types';
|
||||
|
||||
/**
|
||||
* The header name used by the IAP.
|
||||
*/
|
||||
export const DEFAULT_IAP_JWT_HEADER = 'x-goog-iap-jwt-assertion';
|
||||
import {
|
||||
GcpIapTokenInfo as _GcpIapTokenInfo,
|
||||
GcpIapResult as _GcpIapResult,
|
||||
} from '@backstage/plugin-auth-backend-module-gcp-iap-provider';
|
||||
|
||||
/**
|
||||
* The data extracted from an IAP token.
|
||||
*
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-backend-module-gcp-iap-provider` instead
|
||||
*/
|
||||
export type GcpIapTokenInfo = {
|
||||
/**
|
||||
* The unique, stable identifier for the user.
|
||||
*/
|
||||
sub: string;
|
||||
/**
|
||||
* User email address.
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* Other fields.
|
||||
*/
|
||||
[key: string]: JsonValue;
|
||||
};
|
||||
export type GcpIapTokenInfo = _GcpIapTokenInfo;
|
||||
|
||||
/**
|
||||
* The result of the initial auth challenge. This is the input to the auth
|
||||
* callbacks.
|
||||
*
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-backend-module-gcp-iap-provider` instead
|
||||
*/
|
||||
export type GcpIapResult = {
|
||||
/**
|
||||
* The data extracted from the IAP token header.
|
||||
*/
|
||||
iapToken: GcpIapTokenInfo;
|
||||
};
|
||||
|
||||
/**
|
||||
* The provider info to return to the frontend.
|
||||
*/
|
||||
export type GcpIapProviderInfo = {
|
||||
/**
|
||||
* The data extracted from the IAP token header.
|
||||
*/
|
||||
iapToken: GcpIapTokenInfo;
|
||||
};
|
||||
|
||||
/**
|
||||
* The shape of the response to return to callers.
|
||||
*/
|
||||
export type GcpIapResponse = AuthResponse<GcpIapProviderInfo>;
|
||||
export type GcpIapResult = _GcpIapResult;
|
||||
|
||||
@@ -14,74 +14,57 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { GoogleAuthProvider } from './provider';
|
||||
import * as helpers from '../../lib/passport/PassportStrategyHelper';
|
||||
import { OAuthResult } from '../../lib/oauth';
|
||||
import { AuthResolverContext } from '../types';
|
||||
import { googleAuthenticator } from '@backstage/plugin-auth-backend-module-google-provider';
|
||||
import { createOAuthProviderFactory } from '@backstage/plugin-auth-node';
|
||||
import { google } from './provider';
|
||||
|
||||
jest.mock('../../lib/passport/PassportStrategyHelper', () => {
|
||||
return {
|
||||
executeFrameHandlerStrategy: jest.fn(),
|
||||
executeRefreshTokenStrategy: jest.fn(),
|
||||
executeFetchUserProfileStrategy: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockFrameHandler = jest.spyOn(
|
||||
helpers,
|
||||
'executeFrameHandlerStrategy',
|
||||
) as unknown as jest.MockedFunction<
|
||||
() => Promise<{ result: OAuthResult; privateInfo: any }>
|
||||
>;
|
||||
jest.mock('@backstage/plugin-auth-node', () => ({
|
||||
...jest.requireActual('@backstage/plugin-auth-node'),
|
||||
createOAuthProviderFactory: jest.fn(() => 'provider-factory'),
|
||||
}));
|
||||
|
||||
describe('createGoogleProvider', () => {
|
||||
it('should auth', async () => {
|
||||
const provider = new GoogleAuthProvider({
|
||||
resolverContext: {} as AuthResolverContext,
|
||||
authHandler: async ({ fullProfile }) => ({
|
||||
profile: {
|
||||
email: fullProfile.emails![0]!.value,
|
||||
displayName: fullProfile.displayName,
|
||||
picture: 'http://google.com/lols',
|
||||
},
|
||||
}),
|
||||
clientId: 'mock',
|
||||
clientSecret: 'mock',
|
||||
callbackUrl: 'mock',
|
||||
});
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
mockFrameHandler.mockResolvedValueOnce({
|
||||
result: {
|
||||
fullProfile: {
|
||||
emails: [{ value: 'conrad@example.com' }],
|
||||
displayName: 'Conrad',
|
||||
id: 'conrad',
|
||||
provider: 'google',
|
||||
},
|
||||
params: {
|
||||
id_token: 'idToken',
|
||||
scope: 'scope',
|
||||
expires_in: 123,
|
||||
},
|
||||
accessToken: 'accessToken',
|
||||
},
|
||||
privateInfo: {
|
||||
refreshToken: 'wacka',
|
||||
},
|
||||
it('should be created', async () => {
|
||||
expect(google.create()).toBe('provider-factory');
|
||||
|
||||
expect(createOAuthProviderFactory).toHaveBeenCalledWith({
|
||||
authenticator: googleAuthenticator,
|
||||
});
|
||||
const { response } = await provider.handler({} as any);
|
||||
expect(response).toEqual({
|
||||
providerInfo: {
|
||||
accessToken: 'accessToken',
|
||||
expiresInSeconds: 123,
|
||||
idToken: 'idToken',
|
||||
scope: 'scope',
|
||||
},
|
||||
profile: {
|
||||
email: 'conrad@example.com',
|
||||
displayName: 'Conrad',
|
||||
picture: 'http://google.com/lols',
|
||||
},
|
||||
});
|
||||
|
||||
it('should be created with sign-in resolver', async () => {
|
||||
expect(google.create({ signIn: { resolver: jest.fn() } })).toBe(
|
||||
'provider-factory',
|
||||
);
|
||||
|
||||
expect(createOAuthProviderFactory).toHaveBeenCalledWith({
|
||||
authenticator: googleAuthenticator,
|
||||
signInResolver: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should be created with sign-in resolver and auth handler', async () => {
|
||||
expect(
|
||||
google.create({
|
||||
signIn: { resolver: jest.fn() },
|
||||
authHandler: jest.fn(),
|
||||
}),
|
||||
).toBe('provider-factory');
|
||||
|
||||
expect(createOAuthProviderFactory).toHaveBeenCalledWith({
|
||||
authenticator: googleAuthenticator,
|
||||
signInResolver: expect.any(Function),
|
||||
profileTransform: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('should have resolvers', () => {
|
||||
expect(google.resolvers).toEqual({
|
||||
emailLocalPartMatchingUserEntityName: expect.any(Function),
|
||||
emailMatchingUserEntityAnnotation: expect.any(Function),
|
||||
emailMatchingUserEntityProfileEmail: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,166 +14,22 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import passport from 'passport';
|
||||
import { OAuth2Client } from 'google-auth-library';
|
||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||
import {
|
||||
encodeState,
|
||||
OAuthAdapter,
|
||||
OAuthEnvironmentHandler,
|
||||
OAuthHandlers,
|
||||
OAuthProviderOptions,
|
||||
OAuthRefreshRequest,
|
||||
OAuthResponse,
|
||||
OAuthResult,
|
||||
OAuthStartRequest,
|
||||
OAuthLogoutRequest,
|
||||
} from '../../lib/oauth';
|
||||
googleAuthenticator,
|
||||
googleSignInResolvers,
|
||||
} from '@backstage/plugin-auth-backend-module-google-provider';
|
||||
import {
|
||||
executeFetchUserProfileStrategy,
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
executeRefreshTokenStrategy,
|
||||
makeProfileInfo,
|
||||
PassportDoneCallback,
|
||||
} from '../../lib/passport';
|
||||
commonSignInResolvers,
|
||||
createOAuthProviderFactory,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import {
|
||||
AuthHandler,
|
||||
AuthResolverContext,
|
||||
OAuthStartResponse,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
adaptLegacyOAuthHandler,
|
||||
adaptLegacyOAuthSignInResolver,
|
||||
adaptOAuthSignInResolverToLegacy,
|
||||
} from '../../lib/legacy';
|
||||
import { OAuthResult } from '../../lib/oauth';
|
||||
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
|
||||
import {
|
||||
commonByEmailLocalPartResolver,
|
||||
commonByEmailResolver,
|
||||
} from '../resolvers';
|
||||
|
||||
type PrivateInfo = {
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
type Options = OAuthProviderOptions & {
|
||||
signInResolver?: SignInResolver<OAuthResult>;
|
||||
authHandler: AuthHandler<OAuthResult>;
|
||||
resolverContext: AuthResolverContext;
|
||||
};
|
||||
|
||||
export class GoogleAuthProvider implements OAuthHandlers {
|
||||
private readonly strategy: GoogleStrategy;
|
||||
private readonly signInResolver?: SignInResolver<OAuthResult>;
|
||||
private readonly authHandler: AuthHandler<OAuthResult>;
|
||||
private readonly resolverContext: AuthResolverContext;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.authHandler = options.authHandler;
|
||||
this.signInResolver = options.signInResolver;
|
||||
this.resolverContext = options.resolverContext;
|
||||
this.strategy = new GoogleStrategy(
|
||||
{
|
||||
clientID: options.clientId,
|
||||
clientSecret: options.clientSecret,
|
||||
callbackURL: options.callbackUrl,
|
||||
passReqToCallback: false,
|
||||
},
|
||||
(
|
||||
accessToken: any,
|
||||
refreshToken: any,
|
||||
params: any,
|
||||
fullProfile: passport.Profile,
|
||||
done: PassportDoneCallback<OAuthResult, PrivateInfo>,
|
||||
) => {
|
||||
done(
|
||||
undefined,
|
||||
{
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
},
|
||||
{
|
||||
refreshToken,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async start(req: OAuthStartRequest): Promise<OAuthStartResponse> {
|
||||
return await executeRedirectStrategy(req, this.strategy, {
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
scope: req.scope,
|
||||
state: encodeState(req.state),
|
||||
});
|
||||
}
|
||||
|
||||
async handler(req: express.Request) {
|
||||
const { result, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OAuthResult,
|
||||
PrivateInfo
|
||||
>(req, this.strategy);
|
||||
|
||||
return {
|
||||
response: await this.handleResult(result),
|
||||
refreshToken: privateInfo.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async logout(req: OAuthLogoutRequest) {
|
||||
const oauthClient = new OAuth2Client();
|
||||
await oauthClient.revokeToken(req.refreshToken);
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest) {
|
||||
const { accessToken, refreshToken, params } =
|
||||
await executeRefreshTokenStrategy(
|
||||
this.strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
const fullProfile = await executeFetchUserProfileStrategy(
|
||||
this.strategy,
|
||||
accessToken,
|
||||
);
|
||||
|
||||
return {
|
||||
response: await this.handleResult({
|
||||
fullProfile,
|
||||
params,
|
||||
accessToken,
|
||||
}),
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleResult(result: OAuthResult) {
|
||||
const { profile } = await this.authHandler(result, this.resolverContext);
|
||||
|
||||
const response: OAuthResponse = {
|
||||
providerInfo: {
|
||||
idToken: result.params.id_token,
|
||||
accessToken: result.accessToken,
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
},
|
||||
profile,
|
||||
};
|
||||
|
||||
if (this.signInResolver) {
|
||||
response.backstageIdentity = await this.signInResolver(
|
||||
{
|
||||
result,
|
||||
profile,
|
||||
},
|
||||
this.resolverContext,
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
import { AuthHandler, SignInResolver } from '../types';
|
||||
|
||||
/**
|
||||
* Auth provider integration for Google auth
|
||||
@@ -198,62 +54,18 @@ export const google = createAuthProviderIntegration({
|
||||
resolver: SignInResolver<OAuthResult>;
|
||||
};
|
||||
}) {
|
||||
return ({ providerId, globalConfig, config, resolverContext }) =>
|
||||
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
|
||||
const clientId = envConfig.getString('clientId');
|
||||
const clientSecret = envConfig.getString('clientSecret');
|
||||
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
|
||||
const callbackUrl =
|
||||
customCallbackUrl ||
|
||||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
|
||||
|
||||
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
|
||||
? options.authHandler
|
||||
: async ({ fullProfile, params }) => ({
|
||||
profile: makeProfileInfo(fullProfile, params.id_token),
|
||||
});
|
||||
|
||||
const provider = new GoogleAuthProvider({
|
||||
clientId,
|
||||
clientSecret,
|
||||
callbackUrl,
|
||||
signInResolver: options?.signIn?.resolver,
|
||||
authHandler,
|
||||
resolverContext,
|
||||
});
|
||||
|
||||
return OAuthAdapter.fromConfig(globalConfig, provider, {
|
||||
providerId,
|
||||
callbackUrl,
|
||||
});
|
||||
});
|
||||
},
|
||||
resolvers: {
|
||||
/**
|
||||
* Looks up the user by matching their email local part to the entity name.
|
||||
*/
|
||||
emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver,
|
||||
/**
|
||||
* Looks up the user by matching their email to the entity email.
|
||||
*/
|
||||
emailMatchingUserEntityProfileEmail: () => commonByEmailResolver,
|
||||
/**
|
||||
* Looks up the user by matching their email to the `google.com/email` annotation.
|
||||
*/
|
||||
emailMatchingUserEntityAnnotation(): SignInResolver<OAuthResult> {
|
||||
return async (info, ctx) => {
|
||||
const { profile } = info;
|
||||
|
||||
if (!profile.email) {
|
||||
throw new Error('Google profile contained no email');
|
||||
}
|
||||
|
||||
return ctx.signInWithCatalogUser({
|
||||
annotations: {
|
||||
'google.com/email': profile.email,
|
||||
},
|
||||
});
|
||||
};
|
||||
},
|
||||
return createOAuthProviderFactory({
|
||||
authenticator: googleAuthenticator,
|
||||
profileTransform: adaptLegacyOAuthHandler(options?.authHandler),
|
||||
signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver),
|
||||
});
|
||||
},
|
||||
resolvers: adaptOAuthSignInResolverToLegacy({
|
||||
emailLocalPartMatchingUserEntityName:
|
||||
commonSignInResolvers.emailLocalPartMatchingUserEntityName(),
|
||||
emailMatchingUserEntityProfileEmail:
|
||||
commonSignInResolvers.emailMatchingUserEntityProfileEmail(),
|
||||
emailMatchingUserEntityAnnotation:
|
||||
googleSignInResolvers.emailMatchingUserEntityAnnotation(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -45,6 +45,9 @@ describe('MicrosoftAuthProvider', () => {
|
||||
},
|
||||
})({
|
||||
providerId: 'microsoft',
|
||||
baseUrl: 'http://backstage.test/api/auth',
|
||||
appUrl: 'http://backstage.test',
|
||||
isOriginAllowed: _ => true,
|
||||
globalConfig: {
|
||||
baseUrl: 'http://backstage.test/api/auth',
|
||||
appUrl: 'http://backstage.test',
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
commonByEmailLocalPartResolver,
|
||||
commonByEmailResolver,
|
||||
} from '../resolvers';
|
||||
import { Logger } from 'winston';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import fetch from 'node-fetch';
|
||||
import { decodeJwt } from 'jose';
|
||||
import { Profile as PassportProfile } from 'passport';
|
||||
@@ -60,7 +60,7 @@ type PrivateInfo = {
|
||||
type Options = OAuthProviderOptions & {
|
||||
signInResolver?: SignInResolver<OAuthResult>;
|
||||
authHandler: AuthHandler<OAuthResult>;
|
||||
logger: Logger;
|
||||
logger: LoggerService;
|
||||
resolverContext: AuthResolverContext;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
@@ -70,7 +70,7 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
|
||||
private readonly _strategy: MicrosoftStrategy;
|
||||
private readonly signInResolver?: SignInResolver<OAuthResult>;
|
||||
private readonly authHandler: AuthHandler<OAuthResult>;
|
||||
private readonly logger: Logger;
|
||||
private readonly logger: LoggerService;
|
||||
private readonly resolverContext: AuthResolverContext;
|
||||
|
||||
constructor(options: Options) {
|
||||
|
||||
@@ -22,7 +22,7 @@ jest.mock('@backstage/catalog-client');
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import express from 'express';
|
||||
import * as jose from 'jose';
|
||||
import { Logger } from 'winston';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { AuthHandler, AuthResolverContext, SignInResolver } from '../types';
|
||||
import {
|
||||
oauth2Proxy,
|
||||
@@ -36,7 +36,7 @@ describe('Oauth2ProxyAuthProvider', () => {
|
||||
'eyblob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvamltbXltYXJrdW0iLCJlbnQiOlsidXNlcjpkZWZhdWx0L2ppbW15bWFya3VtIl19.eyblob';
|
||||
|
||||
let provider: Oauth2ProxyAuthProvider<any>;
|
||||
let logger: jest.Mocked<Logger>;
|
||||
let logger: jest.Mocked<LoggerService>;
|
||||
let signInResolver: jest.MockedFunction<
|
||||
SignInResolver<OAuth2ProxyResult<any>>
|
||||
>;
|
||||
@@ -53,7 +53,7 @@ describe('Oauth2ProxyAuthProvider', () => {
|
||||
>;
|
||||
authHandler = jest.fn();
|
||||
signInResolver = jest.fn();
|
||||
logger = { error: jest.fn() } as unknown as jest.Mocked<Logger>;
|
||||
logger = { error: jest.fn() } as unknown as jest.Mocked<LoggerService>;
|
||||
|
||||
mockResponse = {
|
||||
status: jest.fn(),
|
||||
|
||||
@@ -14,34 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
BackstageIdentityResponse,
|
||||
BackstageSignInResult,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
|
||||
function parseJwtPayload(token: string) {
|
||||
const [_header, payload, _signature] = token.split('.');
|
||||
return JSON.parse(Buffer.from(payload, 'base64').toString());
|
||||
}
|
||||
import { prepareBackstageIdentityResponse as _prepareBackstageIdentityResponse } from '@backstage/plugin-auth-node';
|
||||
|
||||
/**
|
||||
* Parses a Backstage-issued token and decorates the
|
||||
* {@link @backstage/plugin-auth-node#BackstageIdentityResponse} with identity information sourced from the
|
||||
* token.
|
||||
*
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export function prepareBackstageIdentityResponse(
|
||||
result: BackstageSignInResult,
|
||||
): BackstageIdentityResponse {
|
||||
const { sub, ent } = parseJwtPayload(result.token);
|
||||
|
||||
return {
|
||||
...result,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: sub,
|
||||
ownershipEntityRefs: ent ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
export const prepareBackstageIdentityResponse =
|
||||
_prepareBackstageIdentityResponse;
|
||||
|
||||
@@ -14,126 +14,42 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { GetEntitiesRequest } from '@backstage/catalog-client';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
BackstageIdentityResponse,
|
||||
BackstageSignInResult,
|
||||
AuthProviderConfig as _AuthProviderConfig,
|
||||
AuthProviderRouteHandlers as _AuthProviderRouteHandlers,
|
||||
AuthProviderFactory as _AuthProviderFactory,
|
||||
AuthResolverCatalogUserQuery as _AuthResolverCatalogUserQuery,
|
||||
AuthResolverContext as _AuthResolverContext,
|
||||
ClientAuthResponse as _ClientAuthResponse,
|
||||
CookieConfigurer as _CookieConfigurer,
|
||||
ProfileInfo as _ProfileInfo,
|
||||
SignInInfo as _SignInInfo,
|
||||
SignInResolver as _SignInResolver,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import express from 'express';
|
||||
import { Logger } from 'winston';
|
||||
import { TokenParams } from '../identity/types';
|
||||
import { OAuthStartRequest } from '../lib/oauth/types';
|
||||
|
||||
/**
|
||||
* A query for a single user in the catalog.
|
||||
*
|
||||
* If `entityRef` is used, the default kind is `'User'`.
|
||||
*
|
||||
* If `annotations` are used, all annotations must be present and
|
||||
* match the provided value exactly. Only entities of kind `'User'` will be considered.
|
||||
*
|
||||
* If `filter` are used they are passed on as they are to the `CatalogApi`.
|
||||
*
|
||||
* Regardless of the query method, the query must match exactly one entity
|
||||
* in the catalog, or an error will be thrown.
|
||||
*
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type AuthResolverCatalogUserQuery =
|
||||
| {
|
||||
entityRef:
|
||||
| string
|
||||
| {
|
||||
kind?: string;
|
||||
namespace?: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
annotations: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
filter: Exclude<GetEntitiesRequest['filter'], undefined>;
|
||||
};
|
||||
export type AuthResolverCatalogUserQuery = _AuthResolverCatalogUserQuery;
|
||||
|
||||
/**
|
||||
* The context that is used for auth processing.
|
||||
*
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type AuthResolverContext = {
|
||||
/**
|
||||
* Issues a Backstage token using the provided parameters.
|
||||
*/
|
||||
issueToken(params: TokenParams): Promise<{ token: string }>;
|
||||
|
||||
/**
|
||||
* Finds a single user in the catalog using the provided query.
|
||||
*
|
||||
* See {@link AuthResolverCatalogUserQuery} for details.
|
||||
*/
|
||||
findCatalogUser(
|
||||
query: AuthResolverCatalogUserQuery,
|
||||
): Promise<{ entity: Entity }>;
|
||||
|
||||
/**
|
||||
* Finds a single user in the catalog using the provided query, and then
|
||||
* issues an identity for that user using default ownership resolution.
|
||||
*
|
||||
* See {@link AuthResolverCatalogUserQuery} for details.
|
||||
*/
|
||||
signInWithCatalogUser(
|
||||
query: AuthResolverCatalogUserQuery,
|
||||
): Promise<BackstageSignInResult>;
|
||||
};
|
||||
export type AuthResolverContext = _AuthResolverContext;
|
||||
|
||||
/**
|
||||
* The callback used to resolve the cookie configuration for auth providers that use cookies.
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type CookieConfigurer = (ctx: {
|
||||
/** ID of the auth provider that this configuration applies to */
|
||||
providerId: string;
|
||||
/** The externally reachable base URL of the auth-backend plugin */
|
||||
baseUrl: string;
|
||||
/** The configured callback URL of the auth provider */
|
||||
callbackUrl: string;
|
||||
/** The origin URL of the app */
|
||||
appOrigin: string;
|
||||
}) => {
|
||||
domain: string;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
sameSite?: 'none' | 'lax' | 'strict';
|
||||
};
|
||||
export type CookieConfigurer = _CookieConfigurer;
|
||||
|
||||
/** @public */
|
||||
export type AuthProviderConfig = {
|
||||
/**
|
||||
* The protocol://domain[:port] where the app is hosted. This is used to construct the
|
||||
* callbackURL to redirect to once the user signs in to the auth provider.
|
||||
*/
|
||||
baseUrl: string;
|
||||
|
||||
/**
|
||||
* The base URL of the app as provided by app.baseUrl
|
||||
*/
|
||||
appUrl: string;
|
||||
|
||||
/**
|
||||
* A function that is called to check whether an origin is allowed to receive the authentication result.
|
||||
*/
|
||||
isOriginAllowed: (origin: string) => boolean;
|
||||
|
||||
/**
|
||||
* The function used to resolve cookie configuration based on the auth provider options.
|
||||
*/
|
||||
cookieConfigurer?: CookieConfigurer;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `createOAuthAuthenticator` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type OAuthStartResponse = {
|
||||
/**
|
||||
* URL to redirect to
|
||||
@@ -146,138 +62,53 @@ export type OAuthStartResponse = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Any Auth provider needs to implement this interface which handles the routes in the
|
||||
* auth backend. Any auth API requests from the frontend reaches these methods.
|
||||
*
|
||||
* The routes in the auth backend API are tied to these methods like below
|
||||
*
|
||||
* `/auth/[provider]/start -> start`
|
||||
* `/auth/[provider]/handler/frame -> frameHandler`
|
||||
* `/auth/[provider]/refresh -> refresh`
|
||||
* `/auth/[provider]/logout -> logout`
|
||||
*
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export interface AuthProviderRouteHandlers {
|
||||
/**
|
||||
* Handles the start route of the API. This initiates a sign in request with an auth provider.
|
||||
*
|
||||
* Request
|
||||
* - scopes for the auth request (Optional)
|
||||
* Response
|
||||
* - redirect to the auth provider for the user to sign in or consent.
|
||||
* - sets a nonce cookie and also pass the nonce as 'state' query parameter in the redirect request
|
||||
*/
|
||||
start(req: express.Request, res: express.Response): Promise<void>;
|
||||
|
||||
/**
|
||||
* Once the user signs in or consents in the OAuth screen, the auth provider redirects to the
|
||||
* callbackURL which is handled by this method.
|
||||
*
|
||||
* Request
|
||||
* - to contain a nonce cookie and a 'state' query parameter
|
||||
* Response
|
||||
* - postMessage to the window with a payload that contains accessToken, expiryInSeconds?, idToken? and scope.
|
||||
* - sets a refresh token cookie if the auth provider supports refresh tokens
|
||||
*/
|
||||
frameHandler(req: express.Request, res: express.Response): Promise<void>;
|
||||
|
||||
/**
|
||||
* (Optional) If the auth provider supports refresh tokens then this method handles
|
||||
* requests to get a new access token.
|
||||
*
|
||||
* Request
|
||||
* - to contain a refresh token cookie and scope (Optional) query parameter.
|
||||
* Response
|
||||
* - payload with accessToken, expiryInSeconds?, idToken?, scope and user profile information.
|
||||
*/
|
||||
refresh?(req: express.Request, res: express.Response): Promise<void>;
|
||||
|
||||
/**
|
||||
* (Optional) Handles sign out requests
|
||||
*
|
||||
* Response
|
||||
* - removes the refresh token cookie
|
||||
*/
|
||||
logout?(req: express.Request, res: express.Response): Promise<void>;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type AuthProviderFactory = (options: {
|
||||
providerId: string;
|
||||
globalConfig: AuthProviderConfig;
|
||||
config: Config;
|
||||
logger: Logger;
|
||||
resolverContext: AuthResolverContext;
|
||||
}) => AuthProviderRouteHandlers;
|
||||
|
||||
/** @public */
|
||||
export type AuthResponse<ProviderInfo> = {
|
||||
providerInfo: ProviderInfo;
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity?: BackstageIdentityResponse;
|
||||
};
|
||||
export type AuthProviderConfig = _AuthProviderConfig;
|
||||
|
||||
/**
|
||||
* Used to display login information to user, i.e. sidebar popup.
|
||||
*
|
||||
* It is also temporarily used as the profile of the signed-in user's Backstage
|
||||
* identity, but we want to replace that with data from identity and/org catalog
|
||||
* service
|
||||
*
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type ProfileInfo = {
|
||||
/**
|
||||
* Email ID of the signed in user.
|
||||
*/
|
||||
email?: string;
|
||||
/**
|
||||
* Display name that can be presented to the signed in user.
|
||||
*/
|
||||
displayName?: string;
|
||||
/**
|
||||
* URL to an image that can be used as the display image or avatar of the
|
||||
* signed in user.
|
||||
*/
|
||||
picture?: string;
|
||||
};
|
||||
export type AuthProviderRouteHandlers = _AuthProviderRouteHandlers;
|
||||
|
||||
/**
|
||||
* Type of sign in information context. Includes the profile information and
|
||||
* authentication result which contains auth related information.
|
||||
*
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type SignInInfo<TAuthResult> = {
|
||||
/**
|
||||
* The simple profile passed down for use in the frontend.
|
||||
*/
|
||||
profile: ProfileInfo;
|
||||
|
||||
/**
|
||||
* The authentication result that was received from the authentication
|
||||
* provider.
|
||||
*/
|
||||
result: TAuthResult;
|
||||
};
|
||||
export type AuthProviderFactory = _AuthProviderFactory;
|
||||
|
||||
/**
|
||||
* Describes the function which handles the result of a successful
|
||||
* authentication. Must return a valid {@link @backstage/plugin-auth-node#BackstageSignInResult}.
|
||||
*
|
||||
* @public
|
||||
* @deprecated import `ClientAuthResponse` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type SignInResolver<TAuthResult> = (
|
||||
info: SignInInfo<TAuthResult>,
|
||||
context: AuthResolverContext,
|
||||
) => Promise<BackstageSignInResult>;
|
||||
export type AuthResponse<TProviderInfo> = _ClientAuthResponse<TProviderInfo>;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type ProfileInfo = _ProfileInfo;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type SignInInfo<TAuthResult> = _SignInInfo<TAuthResult>;
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated import from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type SignInResolver<TAuthResult> = _SignInResolver<TAuthResult>;
|
||||
|
||||
/**
|
||||
* The return type of an authentication handler. Must contain valid profile
|
||||
* information.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type AuthHandlerResult = { profile: ProfileInfo };
|
||||
|
||||
@@ -293,13 +124,17 @@ export type AuthHandlerResult = { profile: ProfileInfo };
|
||||
* group of users.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type AuthHandler<TAuthResult> = (
|
||||
input: TAuthResult,
|
||||
context: AuthResolverContext,
|
||||
) => Promise<AuthHandlerResult>;
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead
|
||||
*/
|
||||
export type StateEncoder = (
|
||||
req: OAuthStartRequest,
|
||||
) => Promise<{ encodedState: string }>;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { Logger } from 'winston';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
defaultAuthProviderFactories,
|
||||
AuthProviderFactory,
|
||||
@@ -44,7 +44,7 @@ export type ProviderFactories = { [s: string]: AuthProviderFactory };
|
||||
|
||||
/** @public */
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
logger: LoggerService;
|
||||
database: PluginDatabaseManager;
|
||||
config: Config;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
@@ -130,6 +130,9 @@ export async function createRouter(
|
||||
try {
|
||||
const provider = providerFactory({
|
||||
providerId,
|
||||
appUrl,
|
||||
baseUrl: authUrl,
|
||||
isOriginAllowed,
|
||||
globalConfig: {
|
||||
baseUrl: authUrl,
|
||||
appUrl,
|
||||
|
||||
@@ -23,11 +23,11 @@ import {
|
||||
} from '@backstage/backend-common';
|
||||
import { Server } from 'http';
|
||||
import Knex from 'knex';
|
||||
import { Logger } from 'winston';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { createRouter } from './router';
|
||||
|
||||
export interface ServerOptions {
|
||||
logger: Logger;
|
||||
logger: LoggerService;
|
||||
}
|
||||
|
||||
export async function startStandaloneServer(
|
||||
|
||||
@@ -3,8 +3,100 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { BackstageIdentityResponse as BackstageIdentityResponse_2 } from '@backstage/plugin-auth-node';
|
||||
import { BackstageSignInResult as BackstageSignInResult_2 } from '@backstage/plugin-auth-node';
|
||||
import { Config } from '@backstage/config';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityFilterQuery } from '@backstage/catalog-client';
|
||||
import express from 'express';
|
||||
import { ExtensionPoint } from '@backstage/backend-plugin-api';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { Profile } from 'passport';
|
||||
import { Request as Request_2 } from 'express';
|
||||
import { Response as Response_2 } from 'express';
|
||||
import { Strategy } from 'passport';
|
||||
import { ZodSchema } from 'zod';
|
||||
import { ZodTypeDef } from 'zod';
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type AuthProviderConfig = {
|
||||
baseUrl: string;
|
||||
appUrl: string;
|
||||
isOriginAllowed: (origin: string) => boolean;
|
||||
cookieConfigurer?: CookieConfigurer;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type AuthProviderFactory = (options: {
|
||||
providerId: string;
|
||||
globalConfig: AuthProviderConfig;
|
||||
config: Config;
|
||||
logger: LoggerService;
|
||||
resolverContext: AuthResolverContext;
|
||||
baseUrl: string;
|
||||
appUrl: string;
|
||||
isOriginAllowed: (origin: string) => boolean;
|
||||
cookieConfigurer?: CookieConfigurer;
|
||||
}) => AuthProviderRouteHandlers;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface AuthProviderRegistrationOptions {
|
||||
// (undocumented)
|
||||
factory: AuthProviderFactory;
|
||||
// (undocumented)
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface AuthProviderRouteHandlers {
|
||||
frameHandler(req: Request_2, res: Response_2): Promise<void>;
|
||||
logout?(req: Request_2, res: Response_2): Promise<void>;
|
||||
refresh?(req: Request_2, res: Response_2): Promise<void>;
|
||||
start(req: Request_2, res: Response_2): Promise<void>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface AuthProvidersExtensionPoint {
|
||||
// (undocumented)
|
||||
registerProvider(options: AuthProviderRegistrationOptions): void;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const authProvidersExtensionPoint: ExtensionPoint<AuthProvidersExtensionPoint>;
|
||||
|
||||
// @public
|
||||
export type AuthResolverCatalogUserQuery =
|
||||
| {
|
||||
entityRef:
|
||||
| string
|
||||
| {
|
||||
kind?: string;
|
||||
namespace?: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
annotations: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
filter: EntityFilterQuery;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AuthResolverContext = {
|
||||
issueToken(params: TokenParams): Promise<{
|
||||
token: string;
|
||||
}>;
|
||||
findCatalogUser(query: AuthResolverCatalogUserQuery): Promise<{
|
||||
entity: Entity;
|
||||
}>;
|
||||
signInWithCatalogUser(
|
||||
query: AuthResolverCatalogUserQuery,
|
||||
): Promise<BackstageSignInResult>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export interface BackstageIdentityResponse extends BackstageSignInResult {
|
||||
@@ -23,6 +115,99 @@ export type BackstageUserIdentity = {
|
||||
ownershipEntityRefs: string[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type ClientAuthResponse<TProviderInfo> = {
|
||||
providerInfo: TProviderInfo;
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity?: BackstageIdentityResponse;
|
||||
};
|
||||
|
||||
// @public
|
||||
export namespace commonSignInResolvers {
|
||||
const emailMatchingUserEntityProfileEmail: SignInResolverFactory<
|
||||
unknown,
|
||||
unknown
|
||||
>;
|
||||
const emailLocalPartMatchingUserEntityName: SignInResolverFactory<
|
||||
unknown,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type CookieConfigurer = (ctx: {
|
||||
providerId: string;
|
||||
baseUrl: string;
|
||||
callbackUrl: string;
|
||||
appOrigin: string;
|
||||
}) => {
|
||||
domain: string;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
sameSite?: 'none' | 'lax' | 'strict';
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export function createOAuthAuthenticator<TContext, TProfile>(
|
||||
authenticator: OAuthAuthenticator<TContext, TProfile>,
|
||||
): OAuthAuthenticator<TContext, TProfile>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createOAuthProviderFactory<TProfile>(options: {
|
||||
authenticator: OAuthAuthenticator<unknown, TProfile>;
|
||||
stateTransform?: OAuthStateTransform;
|
||||
profileTransform?: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
|
||||
signInResolver?: SignInResolver<OAuthAuthenticatorResult<TProfile>>;
|
||||
signInResolverFactories?: {
|
||||
[name in string]: SignInResolverFactory<
|
||||
OAuthAuthenticatorResult<TProfile>,
|
||||
unknown
|
||||
>;
|
||||
};
|
||||
}): AuthProviderFactory;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createOAuthRouteHandlers<TProfile>(
|
||||
options: OAuthRouteHandlersOptions<TProfile>,
|
||||
): AuthProviderRouteHandlers;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createProxyAuthenticator<TContext, TResult>(
|
||||
authenticator: ProxyAuthenticator<TContext, TResult>,
|
||||
): ProxyAuthenticator<TContext, TResult>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createProxyAuthProviderFactory<TResult>(options: {
|
||||
authenticator: ProxyAuthenticator<unknown, TResult>;
|
||||
profileTransform?: ProfileTransform<TResult>;
|
||||
signInResolver?: SignInResolver<TResult>;
|
||||
signInResolverFactories?: Record<
|
||||
string,
|
||||
SignInResolverFactory<TResult, unknown>
|
||||
>;
|
||||
}): AuthProviderFactory;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createProxyAuthRouteHandlers<TResult>(
|
||||
options: ProxyAuthRouteHandlersOptions<TResult>,
|
||||
): AuthProviderRouteHandlers;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createSignInResolverFactory<
|
||||
TAuthResult,
|
||||
TOptionsOutput,
|
||||
TOptionsInput,
|
||||
>(
|
||||
options: SignInResolverFactoryOptions<
|
||||
TAuthResult,
|
||||
TOptionsOutput,
|
||||
TOptionsInput
|
||||
>,
|
||||
): SignInResolverFactory<TAuthResult, TOptionsInput>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function decodeOAuthState(encodedState: string): OAuthState;
|
||||
|
||||
// @public
|
||||
export class DefaultIdentityClient implements IdentityApi {
|
||||
// @deprecated
|
||||
@@ -34,6 +219,9 @@ export class DefaultIdentityClient implements IdentityApi {
|
||||
): Promise<BackstageIdentityResponse | undefined>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export function encodeOAuthState(state: OAuthState): string;
|
||||
|
||||
// @public
|
||||
export function getBearerTokenFromAuthorizationHeader(
|
||||
authorizationHeader: unknown,
|
||||
@@ -65,4 +253,391 @@ export type IdentityClientOptions = {
|
||||
issuer?: string;
|
||||
algorithms?: string[];
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export interface OAuthAuthenticator<TContext, TProfile> {
|
||||
// (undocumented)
|
||||
authenticate(
|
||||
input: OAuthAuthenticatorAuthenticateInput,
|
||||
ctx: TContext,
|
||||
): Promise<OAuthAuthenticatorResult<TProfile>>;
|
||||
// (undocumented)
|
||||
defaultProfileTransform: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
|
||||
// (undocumented)
|
||||
initialize(ctx: { callbackUrl: string; config: Config }): TContext;
|
||||
// (undocumented)
|
||||
logout?(input: OAuthAuthenticatorLogoutInput, ctx: TContext): Promise<void>;
|
||||
// (undocumented)
|
||||
refresh(
|
||||
input: OAuthAuthenticatorRefreshInput,
|
||||
ctx: TContext,
|
||||
): Promise<OAuthAuthenticatorResult<TProfile>>;
|
||||
// (undocumented)
|
||||
shouldPersistScopes?: boolean;
|
||||
// (undocumented)
|
||||
start(
|
||||
input: OAuthAuthenticatorStartInput,
|
||||
ctx: TContext,
|
||||
): Promise<{
|
||||
url: string;
|
||||
status?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface OAuthAuthenticatorAuthenticateInput {
|
||||
// (undocumented)
|
||||
req: Request_2;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface OAuthAuthenticatorLogoutInput {
|
||||
// (undocumented)
|
||||
accessToken?: string;
|
||||
// (undocumented)
|
||||
refreshToken?: string;
|
||||
// (undocumented)
|
||||
req: Request_2;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface OAuthAuthenticatorRefreshInput {
|
||||
// (undocumented)
|
||||
refreshToken: string;
|
||||
// (undocumented)
|
||||
req: Request_2;
|
||||
// (undocumented)
|
||||
scope: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface OAuthAuthenticatorResult<TProfile> {
|
||||
// (undocumented)
|
||||
fullProfile: TProfile;
|
||||
// (undocumented)
|
||||
session: OAuthSession;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface OAuthAuthenticatorStartInput {
|
||||
// (undocumented)
|
||||
req: Request_2;
|
||||
// (undocumented)
|
||||
scope: string;
|
||||
// (undocumented)
|
||||
state: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers {
|
||||
constructor(handlers: Map<string, AuthProviderRouteHandlers>);
|
||||
// (undocumented)
|
||||
frameHandler(req: express.Request, res: express.Response): Promise<void>;
|
||||
// (undocumented)
|
||||
logout(req: express.Request, res: express.Response): Promise<void>;
|
||||
// (undocumented)
|
||||
static mapConfig(
|
||||
config: Config,
|
||||
factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers,
|
||||
): OAuthEnvironmentHandler;
|
||||
// (undocumented)
|
||||
refresh(req: express.Request, res: express.Response): Promise<void>;
|
||||
// (undocumented)
|
||||
start(req: express.Request, res: express.Response): Promise<void>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface OAuthRouteHandlersOptions<TProfile> {
|
||||
// (undocumented)
|
||||
appUrl: string;
|
||||
// (undocumented)
|
||||
authenticator: OAuthAuthenticator<any, TProfile>;
|
||||
// (undocumented)
|
||||
baseUrl: string;
|
||||
// (undocumented)
|
||||
config: Config;
|
||||
// (undocumented)
|
||||
cookieConfigurer?: CookieConfigurer;
|
||||
// (undocumented)
|
||||
isOriginAllowed: (origin: string) => boolean;
|
||||
// (undocumented)
|
||||
profileTransform?: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
|
||||
// (undocumented)
|
||||
providerId: string;
|
||||
// (undocumented)
|
||||
resolverContext: AuthResolverContext;
|
||||
// (undocumented)
|
||||
signInResolver?: SignInResolver<OAuthAuthenticatorResult<TProfile>>;
|
||||
// (undocumented)
|
||||
stateTransform?: OAuthStateTransform;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface OAuthSession {
|
||||
// (undocumented)
|
||||
accessToken: string;
|
||||
// (undocumented)
|
||||
expiresInSeconds: number;
|
||||
// (undocumented)
|
||||
idToken?: string;
|
||||
// (undocumented)
|
||||
refreshToken?: string;
|
||||
// (undocumented)
|
||||
scope: string;
|
||||
// (undocumented)
|
||||
tokenType: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type OAuthState = {
|
||||
nonce: string;
|
||||
env: string;
|
||||
origin?: string;
|
||||
scope?: string;
|
||||
redirectUrl?: string;
|
||||
flow?: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type OAuthStateTransform = (
|
||||
state: OAuthState,
|
||||
context: {
|
||||
req: Request_2;
|
||||
},
|
||||
) => Promise<{
|
||||
state: OAuthState;
|
||||
}>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type PassportDoneCallback<TResult, TPrivateInfo = never> = (
|
||||
err?: Error,
|
||||
result?: TResult,
|
||||
privateInfo?: TPrivateInfo,
|
||||
) => void;
|
||||
|
||||
// @public (undocumented)
|
||||
export class PassportHelpers {
|
||||
// (undocumented)
|
||||
static executeFetchUserProfileStrategy(
|
||||
providerStrategy: Strategy,
|
||||
accessToken: string,
|
||||
): Promise<PassportProfile>;
|
||||
// (undocumented)
|
||||
static executeFrameHandlerStrategy<TResult, TPrivateInfo = never>(
|
||||
req: Request_2,
|
||||
providerStrategy: Strategy,
|
||||
options?: Record<string, string>,
|
||||
): Promise<{
|
||||
result: TResult;
|
||||
privateInfo: TPrivateInfo;
|
||||
}>;
|
||||
// (undocumented)
|
||||
static executeRedirectStrategy(
|
||||
req: Request_2,
|
||||
providerStrategy: Strategy,
|
||||
options: Record<string, string>,
|
||||
): Promise<{
|
||||
url: string;
|
||||
status?: number;
|
||||
}>;
|
||||
// (undocumented)
|
||||
static executeRefreshTokenStrategy(
|
||||
providerStrategy: Strategy,
|
||||
refreshToken: string,
|
||||
scope: string,
|
||||
): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
params: any;
|
||||
}>;
|
||||
// (undocumented)
|
||||
static transformProfile: (
|
||||
profile: PassportProfile,
|
||||
idToken?: string,
|
||||
) => ProfileInfo;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export class PassportOAuthAuthenticatorHelper {
|
||||
// (undocumented)
|
||||
authenticate(
|
||||
input: OAuthAuthenticatorAuthenticateInput,
|
||||
): Promise<OAuthAuthenticatorResult<PassportProfile>>;
|
||||
// (undocumented)
|
||||
static defaultProfileTransform: ProfileTransform<
|
||||
OAuthAuthenticatorResult<PassportProfile>
|
||||
>;
|
||||
// (undocumented)
|
||||
fetchProfile(accessToken: string): Promise<PassportProfile>;
|
||||
// (undocumented)
|
||||
static from(strategy: Strategy): PassportOAuthAuthenticatorHelper;
|
||||
// (undocumented)
|
||||
refresh(
|
||||
input: OAuthAuthenticatorRefreshInput,
|
||||
): Promise<OAuthAuthenticatorResult<PassportProfile>>;
|
||||
// (undocumented)
|
||||
start(
|
||||
input: OAuthAuthenticatorStartInput,
|
||||
options: Record<string, string>,
|
||||
): Promise<{
|
||||
url: string;
|
||||
status?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type PassportOAuthDoneCallback = PassportDoneCallback<
|
||||
PassportOAuthResult,
|
||||
PassportOAuthPrivateInfo
|
||||
>;
|
||||
|
||||
// @public (undocumented)
|
||||
export type PassportOAuthPrivateInfo = {
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type PassportOAuthResult = {
|
||||
fullProfile: PassportProfile;
|
||||
params: {
|
||||
id_token?: string;
|
||||
scope: string;
|
||||
token_type?: string;
|
||||
expires_in: number;
|
||||
};
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type PassportProfile = Profile & {
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function prepareBackstageIdentityResponse(
|
||||
result: BackstageSignInResult_2,
|
||||
): BackstageIdentityResponse_2;
|
||||
|
||||
// @public
|
||||
export type ProfileInfo = {
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
picture?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ProfileTransform<TResult> = (
|
||||
result: TResult,
|
||||
context: AuthResolverContext,
|
||||
) => Promise<{
|
||||
profile: ProfileInfo;
|
||||
}>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ProxyAuthenticator<TContext, TResult> {
|
||||
// (undocumented)
|
||||
authenticate(
|
||||
options: {
|
||||
req: Request_2;
|
||||
},
|
||||
ctx: TContext,
|
||||
): Promise<{
|
||||
result: TResult;
|
||||
}>;
|
||||
// (undocumented)
|
||||
defaultProfileTransform: ProfileTransform<TResult>;
|
||||
// (undocumented)
|
||||
initialize(ctx: { config: Config }): Promise<TContext>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ProxyAuthRouteHandlersOptions<TResult> {
|
||||
// (undocumented)
|
||||
authenticator: ProxyAuthenticator<any, TResult>;
|
||||
// (undocumented)
|
||||
config: Config;
|
||||
// (undocumented)
|
||||
profileTransform?: ProfileTransform<TResult>;
|
||||
// (undocumented)
|
||||
resolverContext: AuthResolverContext;
|
||||
// (undocumented)
|
||||
signInResolver: SignInResolver<TResult>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export function readDeclarativeSignInResolver<TAuthResult>(
|
||||
options: ReadDeclarativeSignInResolverOptions<TAuthResult>,
|
||||
): SignInResolver<TAuthResult> | undefined;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface ReadDeclarativeSignInResolverOptions<TAuthResult> {
|
||||
// (undocumented)
|
||||
config: Config;
|
||||
// (undocumented)
|
||||
signInResolverFactories: {
|
||||
[name in string]: SignInResolverFactory<TAuthResult, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export function sendWebMessageResponse(
|
||||
res: Response_2,
|
||||
appOrigin: string,
|
||||
response: WebMessageResponse,
|
||||
): void;
|
||||
|
||||
// @public
|
||||
export type SignInInfo<TAuthResult> = {
|
||||
profile: ProfileInfo;
|
||||
result: TAuthResult;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type SignInResolver<TAuthResult> = (
|
||||
info: SignInInfo<TAuthResult>,
|
||||
context: AuthResolverContext,
|
||||
) => Promise<BackstageSignInResult>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface SignInResolverFactory<TAuthResult, TOptions> {
|
||||
// (undocumented)
|
||||
(
|
||||
...options: undefined extends TOptions
|
||||
? [options?: TOptions]
|
||||
: [options: TOptions]
|
||||
): SignInResolver<TAuthResult>;
|
||||
// (undocumented)
|
||||
optionsJsonSchema?: JsonObject;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface SignInResolverFactoryOptions<
|
||||
TAuthResult,
|
||||
TOptionsOutput,
|
||||
TOptionsInput,
|
||||
> {
|
||||
// (undocumented)
|
||||
create(options: TOptionsOutput): SignInResolver<TAuthResult>;
|
||||
// (undocumented)
|
||||
optionsSchema?: ZodSchema<TOptionsOutput, ZodTypeDef, TOptionsInput>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type TokenParams = {
|
||||
claims: {
|
||||
sub: string;
|
||||
ent?: string[];
|
||||
} & Record<string, JsonValue>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type WebMessageResponse =
|
||||
| {
|
||||
type: 'authorization_response';
|
||||
response: ClientAuthResponse<unknown>;
|
||||
}
|
||||
| {
|
||||
type: 'authorization_response';
|
||||
error: Error;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -29,19 +29,31 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "workspace:^",
|
||||
"@backstage/backend-plugin-api": "workspace:^",
|
||||
"@backstage/catalog-client": "workspace:^",
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@types/express": "*",
|
||||
"@types/passport": "^1.0.3",
|
||||
"express": "^4.17.1",
|
||||
"jose": "^4.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^2.6.7",
|
||||
"winston": "^3.2.1"
|
||||
"passport": "^0.6.0",
|
||||
"winston": "^3.2.1",
|
||||
"zod": "^3.21.4",
|
||||
"zod-to-json-schema": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express-promise-router": "^4.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"msw": "^1.0.0",
|
||||
"supertest": "^6.1.3",
|
||||
"uuid": "^8.0.0"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2023 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 { createExtensionPoint } from '@backstage/backend-plugin-api';
|
||||
import { AuthProviderFactory } from '../types';
|
||||
|
||||
/** @public */
|
||||
export interface AuthProviderRegistrationOptions {
|
||||
providerId: string;
|
||||
factory: AuthProviderFactory;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface AuthProvidersExtensionPoint {
|
||||
registerProvider(options: AuthProviderRegistrationOptions): void;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export const authProvidersExtensionPoint =
|
||||
createExtensionPoint<AuthProvidersExtensionPoint>({
|
||||
id: 'auth.providers',
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
authProvidersExtensionPoint,
|
||||
type AuthProviderRegistrationOptions,
|
||||
type AuthProvidersExtensionPoint,
|
||||
} from './AuthProvidersExtensionPoint';
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2023 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 { WebMessageResponse } from '../sendWebMessageResponse';
|
||||
|
||||
export function parseWebMessageResponse(text: string): {
|
||||
response: WebMessageResponse;
|
||||
origin: string;
|
||||
} {
|
||||
const [response, origin] = text.matchAll(/decodeURIComponent\('(.+?)'\)/g);
|
||||
return {
|
||||
response: JSON.parse(decodeURIComponent(response[1])),
|
||||
origin: decodeURIComponent(origin[1]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
sendWebMessageResponse,
|
||||
type WebMessageResponse,
|
||||
} from './sendWebMessageResponse';
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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 express from 'express';
|
||||
import {
|
||||
safelyEncodeURIComponent,
|
||||
sendWebMessageResponse,
|
||||
type WebMessageResponse,
|
||||
} from './sendWebMessageResponse';
|
||||
import { parseWebMessageResponse } from './__testUtils__/parseWebMessageResponse';
|
||||
|
||||
describe('oauth helpers', () => {
|
||||
describe('safelyEncodeURIComponent', () => {
|
||||
it('encodes all occurrences of single quotes', () => {
|
||||
expect(safelyEncodeURIComponent("a'ö'b")).toBe('a%27%C3%B6%27b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendWebMessageResponse', () => {
|
||||
const appOrigin = 'http://localhost:3000';
|
||||
it('should post a message back with payload success', () => {
|
||||
const mockResponse = {
|
||||
end: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
} as unknown as express.Response;
|
||||
|
||||
const data: WebMessageResponse = {
|
||||
type: 'authorization_response',
|
||||
response: {
|
||||
providerInfo: {
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
idToken: 'ID_TOKEN',
|
||||
expiresInSeconds: 10,
|
||||
scope: 'email',
|
||||
},
|
||||
profile: {
|
||||
email: 'foo@bar.com',
|
||||
},
|
||||
backstageIdentity: {
|
||||
token: 'a.b.c',
|
||||
identity: {
|
||||
type: 'user',
|
||||
ownershipEntityRefs: [],
|
||||
userEntityRef: 'a',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const encoded = safelyEncodeURIComponent(JSON.stringify(data));
|
||||
|
||||
sendWebMessageResponse(mockResponse, appOrigin, data);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledTimes(3);
|
||||
expect(mockResponse.end).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.end).toHaveBeenCalledWith(
|
||||
expect.stringContaining(encoded),
|
||||
);
|
||||
});
|
||||
|
||||
it('should post a message back with payload error', () => {
|
||||
const mockResponse = {
|
||||
end: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
} as unknown as express.Response;
|
||||
|
||||
const data: WebMessageResponse = {
|
||||
type: 'authorization_response',
|
||||
error: new Error('Unknown error occurred'),
|
||||
};
|
||||
const encoded = safelyEncodeURIComponent(
|
||||
JSON.stringify({
|
||||
type: 'authorization_response',
|
||||
error: { name: 'Error', message: 'Unknown error occurred' },
|
||||
}),
|
||||
);
|
||||
|
||||
sendWebMessageResponse(mockResponse, appOrigin, data);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledTimes(3);
|
||||
expect(mockResponse.end).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.end).toHaveBeenCalledWith(
|
||||
expect.stringContaining(encoded),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call postMessage twice but only one of them with target *', () => {
|
||||
let responseBody = '';
|
||||
|
||||
const mockResponse = {
|
||||
end: jest.fn(body => {
|
||||
responseBody = body;
|
||||
return this;
|
||||
}),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
} as unknown as express.Response;
|
||||
|
||||
const data: WebMessageResponse = {
|
||||
type: 'authorization_response',
|
||||
response: {
|
||||
providerInfo: {
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
idToken: 'ID_TOKEN',
|
||||
expiresInSeconds: 10,
|
||||
scope: 'email',
|
||||
},
|
||||
profile: {
|
||||
email: 'foo@bar.com',
|
||||
},
|
||||
backstageIdentity: {
|
||||
token: 'a.b.c',
|
||||
identity: {
|
||||
type: 'user',
|
||||
ownershipEntityRefs: [],
|
||||
userEntityRef: 'a',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
sendWebMessageResponse(mockResponse, appOrigin, data);
|
||||
expect(parseWebMessageResponse(responseBody).response).toEqual({
|
||||
type: 'authorization_response',
|
||||
response: {
|
||||
providerInfo: expect.any(Object),
|
||||
profile: {
|
||||
email: 'foo@bar.com',
|
||||
},
|
||||
backstageIdentity: expect.any(Object),
|
||||
},
|
||||
});
|
||||
|
||||
const errData: WebMessageResponse = {
|
||||
type: 'authorization_response',
|
||||
error: new Error('Unknown error occurred'),
|
||||
};
|
||||
sendWebMessageResponse(mockResponse, appOrigin, errData);
|
||||
expect(parseWebMessageResponse(responseBody).response).toEqual({
|
||||
type: 'authorization_response',
|
||||
error: {
|
||||
name: 'Error',
|
||||
message: 'Unknown error occurred',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles single quotes and unicode chars safely', () => {
|
||||
const mockResponse = {
|
||||
end: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
} as unknown as express.Response;
|
||||
|
||||
const data: WebMessageResponse = {
|
||||
type: 'authorization_response',
|
||||
response: {
|
||||
providerInfo: {
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
idToken: 'ID_TOKEN',
|
||||
expiresInSeconds: 10,
|
||||
scope: 'email',
|
||||
},
|
||||
profile: {
|
||||
email: 'foo@bar.com',
|
||||
displayName: "Adam l'Hôpital",
|
||||
},
|
||||
backstageIdentity: {
|
||||
token: 'a.b.c',
|
||||
identity: {
|
||||
type: 'user',
|
||||
ownershipEntityRefs: [],
|
||||
userEntityRef: 'a',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
sendWebMessageResponse(mockResponse, appOrigin, data);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledTimes(3);
|
||||
expect(mockResponse.end).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.end).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Adam%20l%27H%C3%B4pital'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 { Response } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { ClientAuthResponse } from '../types';
|
||||
import { serializeError } from '@backstage/errors';
|
||||
|
||||
/**
|
||||
* Payload sent as a post message after the auth request is complete.
|
||||
* If successful then has a valid payload with Auth information else contains an error.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type WebMessageResponse =
|
||||
| {
|
||||
type: 'authorization_response';
|
||||
response: ClientAuthResponse<unknown>;
|
||||
}
|
||||
| {
|
||||
type: 'authorization_response';
|
||||
error: Error;
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export function safelyEncodeURIComponent(value: string): string {
|
||||
// Note the g at the end of the regex; all occurrences of single quotes must
|
||||
// be replaced, which encodeURIComponent does not do itself by default
|
||||
return encodeURIComponent(value).replace(/'/g, '%27');
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function sendWebMessageResponse(
|
||||
res: Response,
|
||||
appOrigin: string,
|
||||
response: WebMessageResponse,
|
||||
): void {
|
||||
const jsonData = JSON.stringify(response, (_, value) => {
|
||||
if (value instanceof Error) {
|
||||
return serializeError(value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
const base64Data = safelyEncodeURIComponent(jsonData);
|
||||
const base64Origin = safelyEncodeURIComponent(appOrigin);
|
||||
|
||||
// NOTE: It is absolutely imperative that we use the safe encoder above, to
|
||||
// be sure that the js code below does not allow the injection of malicious
|
||||
// data.
|
||||
|
||||
// TODO: Make target app origin configurable globally
|
||||
|
||||
//
|
||||
// postMessage fails silently if the targetOrigin is disallowed.
|
||||
// So 2 postMessages are sent from the popup to the parent window.
|
||||
// First, the origin being used to post the actual authorization response is
|
||||
// shared with the parent window with a postMessage with targetOrigin '*'.
|
||||
// Second, the actual authorization response is sent with the app origin
|
||||
// as the targetOrigin.
|
||||
// If the first message was received but the actual auth response was
|
||||
// never received, the event listener can conclude that targetOrigin
|
||||
// was disallowed, indicating potential misconfiguration.
|
||||
//
|
||||
const script = `
|
||||
var authResponse = decodeURIComponent('${base64Data}');
|
||||
var origin = decodeURIComponent('${base64Origin}');
|
||||
var originInfo = {'type': 'config_info', 'targetOrigin': origin};
|
||||
(window.opener || window.parent).postMessage(originInfo, '*');
|
||||
(window.opener || window.parent).postMessage(JSON.parse(authResponse), origin);
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 100); // same as the interval of the core-app-api lib/loginPopup.ts (to address race conditions)
|
||||
`;
|
||||
const hash = crypto.createHash('sha256').update(script).digest('base64');
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.setHeader('X-Frame-Options', 'sameorigin');
|
||||
res.setHeader('Content-Security-Policy', `script-src 'sha256-${hash}'`);
|
||||
res.end(`<html><body><script>${script}</script></body></html>`);
|
||||
}
|
||||
+1
-1
@@ -26,7 +26,7 @@ import { setupServer } from 'msw/node';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { DefaultIdentityClient } from './DefaultIdentityClient';
|
||||
import { IdentityApiGetIdentityRequest } from './types';
|
||||
import { IdentityApiGetIdentityRequest } from './IdentityApi';
|
||||
|
||||
interface AnyJWK extends Record<string, string> {
|
||||
use: 'sig';
|
||||
+2
-5
@@ -25,12 +25,9 @@ import {
|
||||
jwtVerify,
|
||||
} from 'jose';
|
||||
import { GetKeyFunction } from 'jose/dist/types/types';
|
||||
import {
|
||||
BackstageIdentityResponse,
|
||||
IdentityApiGetIdentityRequest,
|
||||
} from './types';
|
||||
import { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader';
|
||||
import { IdentityApi } from './IdentityApi';
|
||||
import { IdentityApi, IdentityApiGetIdentityRequest } from './IdentityApi';
|
||||
import { BackstageIdentityResponse } from '../types';
|
||||
|
||||
const CLOCK_MARGIN_S = 10;
|
||||
|
||||
+12
-4
@@ -13,10 +13,18 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
BackstageIdentityResponse,
|
||||
IdentityApiGetIdentityRequest,
|
||||
} from './types';
|
||||
|
||||
import { Request } from 'express';
|
||||
import { BackstageIdentityResponse } from '../types';
|
||||
|
||||
/**
|
||||
* Options to request the identity from a Backstage backend request
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type IdentityApiGetIdentityRequest = {
|
||||
request: Request<unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* An identity client api to authenticate Backstage
|
||||
+1
-1
@@ -18,7 +18,7 @@ import {
|
||||
DefaultIdentityClient,
|
||||
IdentityClientOptions,
|
||||
} from './DefaultIdentityClient';
|
||||
import { BackstageIdentityResponse } from './types';
|
||||
import { BackstageIdentityResponse } from '../types';
|
||||
|
||||
/**
|
||||
* An identity client to interact with auth-backend and authenticate Backstage
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2023 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 { prepareBackstageIdentityResponse } from './prepareBackstageIdentityResponse';
|
||||
export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader';
|
||||
export {
|
||||
DefaultIdentityClient,
|
||||
type IdentityClientOptions,
|
||||
} from './DefaultIdentityClient';
|
||||
export { IdentityClient } from './IdentityClient';
|
||||
export type { IdentityApi, IdentityApiGetIdentityRequest } from './IdentityApi';
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 { InputError } from '@backstage/errors';
|
||||
import {
|
||||
BackstageIdentityResponse,
|
||||
BackstageSignInResult,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
|
||||
function parseJwtPayload(token: string) {
|
||||
const [_header, payload, _signature] = token.split('.');
|
||||
return JSON.parse(Buffer.from(payload, 'base64').toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a Backstage-issued token and decorates the
|
||||
* {@link @backstage/plugin-auth-node#BackstageIdentityResponse} with identity information sourced from the
|
||||
* token.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function prepareBackstageIdentityResponse(
|
||||
result: BackstageSignInResult,
|
||||
): BackstageIdentityResponse {
|
||||
if (!result.token) {
|
||||
throw new InputError(`Identity response must return a token`);
|
||||
}
|
||||
|
||||
const { sub, ent } = parseJwtPayload(result.token);
|
||||
|
||||
return {
|
||||
...result,
|
||||
identity: {
|
||||
type: 'user',
|
||||
userEntityRef: sub,
|
||||
ownershipEntityRefs: ent ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -20,14 +20,27 @@
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader';
|
||||
export { DefaultIdentityClient } from './DefaultIdentityClient';
|
||||
export { IdentityClient } from './IdentityClient';
|
||||
export type { IdentityApi } from './IdentityApi';
|
||||
export type { IdentityClientOptions } from './DefaultIdentityClient';
|
||||
export * from './extensions';
|
||||
export * from './flow';
|
||||
export * from './identity';
|
||||
export * from './oauth';
|
||||
export * from './passport';
|
||||
export * from './proxy';
|
||||
export * from './sign-in';
|
||||
export type {
|
||||
AuthProviderConfig,
|
||||
AuthProviderRouteHandlers,
|
||||
AuthProviderFactory,
|
||||
AuthResolverCatalogUserQuery,
|
||||
AuthResolverContext,
|
||||
BackstageIdentityResponse,
|
||||
BackstageSignInResult,
|
||||
BackstageUserIdentity,
|
||||
IdentityApiGetIdentityRequest,
|
||||
ClientAuthResponse,
|
||||
CookieConfigurer,
|
||||
ProfileInfo,
|
||||
ProfileTransform,
|
||||
SignInInfo,
|
||||
SignInResolver,
|
||||
TokenParams,
|
||||
} from './types';
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Request, Response } from 'express';
|
||||
import { CookieConfigurer } from '../types';
|
||||
|
||||
const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
|
||||
const TEN_MINUTES_MS = 600 * 1000;
|
||||
|
||||
const defaultCookieConfigurer: CookieConfigurer = ({
|
||||
callbackUrl,
|
||||
providerId,
|
||||
appOrigin,
|
||||
}) => {
|
||||
const { hostname: domain, pathname, protocol } = new URL(callbackUrl);
|
||||
const secure = protocol === 'https:';
|
||||
|
||||
// For situations where the auth-backend is running on a
|
||||
// different domain than the app, we set the SameSite attribute
|
||||
// to 'none' to allow third-party access to the cookie, but
|
||||
// only if it's in a secure context (https).
|
||||
let sameSite: ReturnType<CookieConfigurer>['sameSite'] = 'lax';
|
||||
if (new URL(appOrigin).hostname !== domain && secure) {
|
||||
sameSite = 'none';
|
||||
}
|
||||
|
||||
// If the provider supports callbackUrls, the pathname will
|
||||
// contain the complete path to the frame handler so we need
|
||||
// to slice off the trailing part of the path.
|
||||
const path = pathname.endsWith(`${providerId}/handler/frame`)
|
||||
? pathname.slice(0, -'/handler/frame'.length)
|
||||
: `${pathname}/${providerId}`;
|
||||
|
||||
return { domain, path, secure, sameSite };
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export class OAuthCookieManager {
|
||||
private readonly cookieConfigurer: CookieConfigurer;
|
||||
private readonly nonceCookie: string;
|
||||
private readonly refreshTokenCookie: string;
|
||||
private readonly grantedScopeCookie: string;
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
providerId: string;
|
||||
defaultAppOrigin: string;
|
||||
baseUrl: string;
|
||||
callbackUrl: string;
|
||||
cookieConfigurer?: CookieConfigurer;
|
||||
},
|
||||
) {
|
||||
this.cookieConfigurer = options.cookieConfigurer ?? defaultCookieConfigurer;
|
||||
|
||||
this.nonceCookie = `${options.providerId}-nonce`;
|
||||
this.refreshTokenCookie = `${options.providerId}-refresh-token`;
|
||||
this.grantedScopeCookie = `${options.providerId}-granted-scope`;
|
||||
}
|
||||
|
||||
private getConfig(origin?: string, pathSuffix: string = '') {
|
||||
const cookieConfig = this.cookieConfigurer({
|
||||
providerId: this.options.providerId,
|
||||
baseUrl: this.options.baseUrl,
|
||||
callbackUrl: this.options.callbackUrl,
|
||||
appOrigin: origin ?? this.options.defaultAppOrigin,
|
||||
});
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
...cookieConfig,
|
||||
path: cookieConfig.path + pathSuffix,
|
||||
};
|
||||
}
|
||||
|
||||
setNonce(res: Response, nonce: string, origin?: string) {
|
||||
res.cookie(this.nonceCookie, nonce, {
|
||||
maxAge: TEN_MINUTES_MS,
|
||||
...this.getConfig(origin, '/handler'),
|
||||
});
|
||||
}
|
||||
|
||||
setRefreshToken(res: Response, refreshToken: string, origin?: string) {
|
||||
res.cookie(this.refreshTokenCookie, refreshToken, {
|
||||
maxAge: THOUSAND_DAYS_MS,
|
||||
...this.getConfig(origin),
|
||||
});
|
||||
}
|
||||
|
||||
removeRefreshToken(res: Response, origin?: string) {
|
||||
res.cookie(this.refreshTokenCookie, '', {
|
||||
maxAge: 0,
|
||||
...this.getConfig(origin),
|
||||
});
|
||||
}
|
||||
|
||||
setGrantedScopes(res: Response, scope: string, origin?: string) {
|
||||
res.cookie(this.grantedScopeCookie, scope, {
|
||||
maxAge: THOUSAND_DAYS_MS,
|
||||
...this.getConfig(origin),
|
||||
});
|
||||
}
|
||||
|
||||
getNonce(req: Request) {
|
||||
return req.cookies[this.nonceCookie];
|
||||
}
|
||||
|
||||
getRefreshToken(req: Request) {
|
||||
return req.cookies[this.refreshTokenCookie];
|
||||
}
|
||||
|
||||
getGrantedScopes(req: Request) {
|
||||
return req.cookies[this.grantedScopeCookie];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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 express from 'express';
|
||||
import { Config } from '@backstage/config';
|
||||
import { InputError, NotFoundError } from '@backstage/errors';
|
||||
import { AuthProviderRouteHandlers } from '../types';
|
||||
import { decodeOAuthState } from './state';
|
||||
|
||||
/** @public */
|
||||
export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers {
|
||||
static mapConfig(
|
||||
config: Config,
|
||||
factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers,
|
||||
) {
|
||||
const envs = config.keys();
|
||||
const handlers = new Map<string, AuthProviderRouteHandlers>();
|
||||
|
||||
for (const env of envs) {
|
||||
const envConfig = config.getConfig(env);
|
||||
const handler = factoryFunc(envConfig);
|
||||
handlers.set(env, handler);
|
||||
}
|
||||
|
||||
return new OAuthEnvironmentHandler(handlers);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly handlers: Map<string, AuthProviderRouteHandlers>,
|
||||
) {}
|
||||
|
||||
async start(req: express.Request, res: express.Response): Promise<void> {
|
||||
const provider = this.getProviderForEnv(req);
|
||||
await provider.start(req, res);
|
||||
}
|
||||
|
||||
async frameHandler(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<void> {
|
||||
const provider = this.getProviderForEnv(req);
|
||||
await provider.frameHandler(req, res);
|
||||
}
|
||||
|
||||
async refresh(req: express.Request, res: express.Response): Promise<void> {
|
||||
const provider = this.getProviderForEnv(req);
|
||||
await provider.refresh?.(req, res);
|
||||
}
|
||||
|
||||
async logout(req: express.Request, res: express.Response): Promise<void> {
|
||||
const provider = this.getProviderForEnv(req);
|
||||
await provider.logout?.(req, res);
|
||||
}
|
||||
|
||||
private getEnvFromRequest(req: express.Request): string | undefined {
|
||||
const reqEnv = req.query.env?.toString();
|
||||
if (reqEnv) {
|
||||
return reqEnv;
|
||||
}
|
||||
const stateParams = req.query.state?.toString();
|
||||
if (!stateParams) {
|
||||
return undefined;
|
||||
}
|
||||
const { env } = decodeOAuthState(stateParams);
|
||||
return env;
|
||||
}
|
||||
|
||||
private getProviderForEnv(req: express.Request): AuthProviderRouteHandlers {
|
||||
const env: string | undefined = this.getEnvFromRequest(req);
|
||||
|
||||
if (!env) {
|
||||
throw new InputError(`Must specify 'env' query to select environment`);
|
||||
}
|
||||
|
||||
const handler = this.handlers.get(env);
|
||||
if (!handler) {
|
||||
throw new NotFoundError(
|
||||
`No configuration available for the '${env}' environment of this provider.`,
|
||||
);
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Strategy } from 'passport';
|
||||
import {
|
||||
PassportDoneCallback,
|
||||
PassportHelpers,
|
||||
PassportProfile,
|
||||
} from '../passport';
|
||||
import { ProfileTransform } from '../types';
|
||||
import {
|
||||
OAuthAuthenticatorAuthenticateInput,
|
||||
OAuthAuthenticatorRefreshInput,
|
||||
OAuthAuthenticatorResult,
|
||||
OAuthAuthenticatorStartInput,
|
||||
} from './types';
|
||||
|
||||
/** @public */
|
||||
export type PassportOAuthResult = {
|
||||
fullProfile: PassportProfile;
|
||||
params: {
|
||||
id_token?: string;
|
||||
scope: string;
|
||||
token_type?: string;
|
||||
expires_in: number;
|
||||
};
|
||||
accessToken: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type PassportOAuthPrivateInfo = {
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type PassportOAuthDoneCallback = PassportDoneCallback<
|
||||
PassportOAuthResult,
|
||||
PassportOAuthPrivateInfo
|
||||
>;
|
||||
|
||||
/** @public */
|
||||
export class PassportOAuthAuthenticatorHelper {
|
||||
static defaultProfileTransform: ProfileTransform<
|
||||
OAuthAuthenticatorResult<PassportProfile>
|
||||
> = async input => ({
|
||||
profile: PassportHelpers.transformProfile(
|
||||
input.fullProfile,
|
||||
input.session.idToken,
|
||||
),
|
||||
});
|
||||
|
||||
static from(strategy: Strategy) {
|
||||
return new PassportOAuthAuthenticatorHelper(strategy);
|
||||
}
|
||||
|
||||
readonly #strategy: Strategy;
|
||||
|
||||
private constructor(strategy: Strategy) {
|
||||
this.#strategy = strategy;
|
||||
}
|
||||
|
||||
async start(
|
||||
input: OAuthAuthenticatorStartInput,
|
||||
options: Record<string, string>,
|
||||
): Promise<{ url: string; status?: number }> {
|
||||
return PassportHelpers.executeRedirectStrategy(input.req, this.#strategy, {
|
||||
scope: input.scope,
|
||||
state: input.state,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async authenticate(
|
||||
input: OAuthAuthenticatorAuthenticateInput,
|
||||
): Promise<OAuthAuthenticatorResult<PassportProfile>> {
|
||||
const { result, privateInfo } =
|
||||
await PassportHelpers.executeFrameHandlerStrategy<
|
||||
PassportOAuthResult,
|
||||
PassportOAuthPrivateInfo
|
||||
>(input.req, this.#strategy);
|
||||
|
||||
return {
|
||||
fullProfile: result.fullProfile as PassportProfile,
|
||||
session: {
|
||||
accessToken: result.accessToken,
|
||||
tokenType: result.params.token_type ?? 'bearer',
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
idToken: result.params.id_token,
|
||||
refreshToken: privateInfo.refreshToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(
|
||||
input: OAuthAuthenticatorRefreshInput,
|
||||
): Promise<OAuthAuthenticatorResult<PassportProfile>> {
|
||||
const result = await PassportHelpers.executeRefreshTokenStrategy(
|
||||
this.#strategy,
|
||||
input.refreshToken,
|
||||
input.scope,
|
||||
);
|
||||
const fullProfile = await this.fetchProfile(result.accessToken);
|
||||
return {
|
||||
fullProfile,
|
||||
session: {
|
||||
accessToken: result.accessToken,
|
||||
tokenType: result.params.token_type ?? 'bearer',
|
||||
scope: result.params.scope,
|
||||
expiresInSeconds: result.params.expires_in,
|
||||
idToken: result.params.id_token,
|
||||
refreshToken: result.refreshToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async fetchProfile(accessToken: string): Promise<PassportProfile> {
|
||||
const profile = await PassportHelpers.executeFetchUserProfileStrategy(
|
||||
this.#strategy,
|
||||
accessToken,
|
||||
);
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2023 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 { readDeclarativeSignInResolver } from '../sign-in';
|
||||
import {
|
||||
AuthProviderFactory,
|
||||
ProfileTransform,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler';
|
||||
import { createOAuthRouteHandlers } from './createOAuthRouteHandlers';
|
||||
import { OAuthStateTransform } from './state';
|
||||
import { OAuthAuthenticator, OAuthAuthenticatorResult } from './types';
|
||||
import { SignInResolverFactory } from '../sign-in/createSignInResolverFactory';
|
||||
|
||||
/** @public */
|
||||
export function createOAuthProviderFactory<TProfile>(options: {
|
||||
authenticator: OAuthAuthenticator<unknown, TProfile>;
|
||||
stateTransform?: OAuthStateTransform;
|
||||
profileTransform?: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
|
||||
signInResolver?: SignInResolver<OAuthAuthenticatorResult<TProfile>>;
|
||||
signInResolverFactories?: {
|
||||
[name in string]: SignInResolverFactory<
|
||||
OAuthAuthenticatorResult<TProfile>,
|
||||
unknown
|
||||
>;
|
||||
};
|
||||
}): AuthProviderFactory {
|
||||
return ctx => {
|
||||
return OAuthEnvironmentHandler.mapConfig(ctx.config, envConfig => {
|
||||
const signInResolver =
|
||||
options.signInResolver ??
|
||||
readDeclarativeSignInResolver({
|
||||
config: envConfig,
|
||||
signInResolverFactories: options.signInResolverFactories ?? {},
|
||||
});
|
||||
|
||||
return createOAuthRouteHandlers<TProfile>({
|
||||
authenticator: options.authenticator,
|
||||
appUrl: ctx.appUrl,
|
||||
baseUrl: ctx.baseUrl,
|
||||
config: envConfig,
|
||||
isOriginAllowed: ctx.isOriginAllowed,
|
||||
cookieConfigurer: ctx.cookieConfigurer,
|
||||
providerId: ctx.providerId,
|
||||
resolverContext: ctx.resolverContext,
|
||||
stateTransform: options.stateTransform,
|
||||
profileTransform: options.profileTransform,
|
||||
signInResolver,
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
/*
|
||||
* Copyright 2023 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 { ConfigReader } from '@backstage/config';
|
||||
import express from 'express';
|
||||
import request, { SuperAgentTest } from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import PromiseRouter from 'express-promise-router';
|
||||
import { AuthProviderRouteHandlers, AuthResolverContext } from '../types';
|
||||
import { createOAuthRouteHandlers } from './createOAuthRouteHandlers';
|
||||
import { OAuthAuthenticator } from './types';
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
import { encodeOAuthState, OAuthState } from './state';
|
||||
import { PassportProfile } from '../passport';
|
||||
import { parseWebMessageResponse } from '../flow/__testUtils__/parseWebMessageResponse';
|
||||
|
||||
const mockAuthenticator: jest.Mocked<OAuthAuthenticator<unknown, unknown>> = {
|
||||
initialize: jest.fn(_r => ({ ctx: 'authenticator' })),
|
||||
start: jest.fn(),
|
||||
authenticate: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
defaultProfileTransform: jest.fn(async (_r, _c) => ({ profile: {} })),
|
||||
};
|
||||
|
||||
const mockBackstageToken = `a.${btoa(
|
||||
JSON.stringify({ sub: 'user:default/mock', ent: [] }),
|
||||
)}.c`;
|
||||
|
||||
const mockSession = {
|
||||
accessToken: 'access-token',
|
||||
expiresInSeconds: 3,
|
||||
scope: 'my-scope',
|
||||
tokenType: 'bear',
|
||||
idToken: 'id-token',
|
||||
refreshToken: 'refresh-token',
|
||||
};
|
||||
|
||||
const baseConfig = {
|
||||
authenticator: mockAuthenticator,
|
||||
appUrl: 'http://127.0.0.1',
|
||||
baseUrl: 'http://127.0.0.1:7007',
|
||||
isOriginAllowed: () => true,
|
||||
providerId: 'my-provider',
|
||||
config: new ConfigReader({}),
|
||||
resolverContext: { ctx: 'resolver' } as unknown as AuthResolverContext,
|
||||
};
|
||||
|
||||
function wrapInApp(handlers: AuthProviderRouteHandlers) {
|
||||
const app = express();
|
||||
|
||||
const router = PromiseRouter();
|
||||
|
||||
router.use(cookieParser());
|
||||
app.use('/my-provider', router);
|
||||
app.use(errorHandler());
|
||||
|
||||
router.get('/start', handlers.start.bind(handlers));
|
||||
router.get('/handler/frame', handlers.frameHandler.bind(handlers));
|
||||
router.post('/handler/frame', handlers.frameHandler.bind(handlers));
|
||||
if (handlers.logout) {
|
||||
router.post('/logout', handlers.logout.bind(handlers));
|
||||
}
|
||||
if (handlers.refresh) {
|
||||
router.get('/refresh', handlers.refresh.bind(handlers));
|
||||
router.post('/refresh', handlers.refresh.bind(handlers));
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function getNonceCookie(test: SuperAgentTest) {
|
||||
return test.jar.getCookie('my-provider-nonce', {
|
||||
domain: '127.0.0.1',
|
||||
path: '/my-provider/handler',
|
||||
script: false,
|
||||
secure: false,
|
||||
});
|
||||
}
|
||||
|
||||
function getRefreshTokenCookie(test: SuperAgentTest) {
|
||||
return test.jar.getCookie('my-provider-refresh-token', {
|
||||
domain: '127.0.0.1',
|
||||
path: '/my-provider',
|
||||
script: false,
|
||||
secure: false,
|
||||
});
|
||||
}
|
||||
|
||||
function getGrantedScopesCookie(test: SuperAgentTest) {
|
||||
return test.jar.getCookie('my-provider-granted-scope', {
|
||||
domain: '127.0.0.1',
|
||||
path: '/my-provider',
|
||||
script: false,
|
||||
secure: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe('createOAuthRouteHandlers', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('should be created', () => {
|
||||
const handlers = createOAuthRouteHandlers(baseConfig);
|
||||
expect(handlers).toEqual({
|
||||
start: expect.any(Function),
|
||||
frameHandler: expect.any(Function),
|
||||
refresh: expect.any(Function),
|
||||
logout: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('should require an env query', async () => {
|
||||
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
|
||||
const res = await request(app).get('/my-provider/start');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toMatchObject({
|
||||
error: {
|
||||
name: 'InputError',
|
||||
message: 'No env provided in request query parameters',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should start', async () => {
|
||||
const agent = request.agent(
|
||||
wrapInApp(createOAuthRouteHandlers(baseConfig)),
|
||||
);
|
||||
|
||||
mockAuthenticator.start.mockResolvedValue({
|
||||
url: 'https://example.com/redirect',
|
||||
});
|
||||
|
||||
const res = await agent.get(
|
||||
'/my-provider/start?env=development&scope=my-scope',
|
||||
);
|
||||
|
||||
const { value: nonce } = getNonceCookie(agent);
|
||||
|
||||
expect(res.text).toBe('');
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.get('Location')).toBe('https://example.com/redirect');
|
||||
expect(res.get('Content-Length')).toBe('0');
|
||||
|
||||
expect(mockAuthenticator.start).toHaveBeenCalledWith(
|
||||
{
|
||||
req: expect.anything(),
|
||||
scope: 'my-scope',
|
||||
state: encodeOAuthState({
|
||||
nonce: decodeURIComponent(nonce),
|
||||
env: 'development',
|
||||
}),
|
||||
},
|
||||
{ ctx: 'authenticator' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should start with additional parameters, transform state, and persist scopes', async () => {
|
||||
const agent = request.agent(
|
||||
wrapInApp(
|
||||
createOAuthRouteHandlers({
|
||||
...baseConfig,
|
||||
authenticator: {
|
||||
...mockAuthenticator,
|
||||
shouldPersistScopes: true,
|
||||
},
|
||||
stateTransform: async state => ({
|
||||
state: { ...state, nonce: '123' },
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
mockAuthenticator.start.mockResolvedValue({
|
||||
url: 'https://example.com/redirect',
|
||||
});
|
||||
|
||||
const res = await agent.get('/my-provider/start').query({
|
||||
env: 'development',
|
||||
scope: 'my-scope',
|
||||
origin: 'https://remotehost',
|
||||
redirectUrl: 'https://remotehost/redirect',
|
||||
flow: 'redirect',
|
||||
});
|
||||
|
||||
expect(res.text).toBe('');
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.get('Location')).toBe('https://example.com/redirect');
|
||||
expect(res.get('Content-Length')).toBe('0');
|
||||
|
||||
expect(mockAuthenticator.start).toHaveBeenCalledWith(
|
||||
{
|
||||
req: expect.anything(),
|
||||
scope: 'my-scope',
|
||||
state: encodeOAuthState({
|
||||
nonce: '123',
|
||||
env: 'development',
|
||||
origin: 'https://remotehost',
|
||||
redirectUrl: 'https://remotehost/redirect',
|
||||
flow: 'redirect',
|
||||
scope: 'my-scope',
|
||||
}),
|
||||
},
|
||||
{ ctx: 'authenticator' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('frameHandler', () => {
|
||||
it('should authenticate', async () => {
|
||||
const agent = request.agent(
|
||||
wrapInApp(createOAuthRouteHandlers(baseConfig)),
|
||||
);
|
||||
|
||||
agent.jar.setCookie(
|
||||
'my-provider-nonce=123',
|
||||
'127.0.0.1',
|
||||
'/my-provider/handler',
|
||||
);
|
||||
|
||||
mockAuthenticator.authenticate.mockResolvedValue({
|
||||
fullProfile: { id: 'id' } as PassportProfile,
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
const res = await agent.get('/my-provider/handler/frame').query({
|
||||
state: encodeOAuthState({
|
||||
env: 'development',
|
||||
nonce: '123',
|
||||
} as OAuthState),
|
||||
});
|
||||
|
||||
expect(mockAuthenticator.authenticate).toHaveBeenCalledWith(
|
||||
{ req: expect.anything() },
|
||||
{ ctx: 'authenticator' },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(parseWebMessageResponse(res.text).response).toEqual({
|
||||
type: 'authorization_response',
|
||||
response: {
|
||||
profile: {},
|
||||
providerInfo: {
|
||||
accessToken: 'access-token',
|
||||
expiresInSeconds: 3,
|
||||
idToken: 'id-token',
|
||||
scope: 'my-scope',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getRefreshTokenCookie(agent).value).toBe('refresh-token');
|
||||
expect(getGrantedScopesCookie(agent)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should authenticate with sign-in, profile transform, and persisted scopes', async () => {
|
||||
const agent = request.agent(
|
||||
wrapInApp(
|
||||
createOAuthRouteHandlers({
|
||||
...baseConfig,
|
||||
authenticator: {
|
||||
...mockAuthenticator,
|
||||
shouldPersistScopes: true,
|
||||
},
|
||||
profileTransform: async () => ({ profile: { email: 'em@i.l' } }),
|
||||
signInResolver: async () => ({ token: mockBackstageToken }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
agent.jar.setCookie(
|
||||
'my-provider-nonce=123',
|
||||
'127.0.0.1',
|
||||
'/my-provider/handler',
|
||||
);
|
||||
|
||||
mockAuthenticator.authenticate.mockResolvedValue({
|
||||
fullProfile: { id: 'id' } as PassportProfile,
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
const res = await agent.get('/my-provider/handler/frame').query({
|
||||
state: encodeOAuthState({
|
||||
env: 'development',
|
||||
nonce: '123',
|
||||
scope: 'my-scope',
|
||||
} as OAuthState),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(parseWebMessageResponse(res.text).response).toEqual({
|
||||
type: 'authorization_response',
|
||||
response: {
|
||||
profile: { email: 'em@i.l' },
|
||||
providerInfo: {
|
||||
accessToken: 'access-token',
|
||||
expiresInSeconds: 3,
|
||||
idToken: 'id-token',
|
||||
scope: 'my-scope',
|
||||
},
|
||||
backstageIdentity: {
|
||||
identity: {
|
||||
type: 'user',
|
||||
ownershipEntityRefs: [],
|
||||
userEntityRef: 'user:default/mock',
|
||||
},
|
||||
token: mockBackstageToken,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getRefreshTokenCookie(agent).value).toBe('refresh-token');
|
||||
expect(getGrantedScopesCookie(agent).value).toBe('my-scope');
|
||||
});
|
||||
|
||||
it('should redirect with persisted scope', async () => {
|
||||
const agent = request.agent(
|
||||
wrapInApp(
|
||||
createOAuthRouteHandlers({
|
||||
...baseConfig,
|
||||
authenticator: {
|
||||
...mockAuthenticator,
|
||||
shouldPersistScopes: true,
|
||||
},
|
||||
profileTransform: async () => ({ profile: { email: 'em@i.l' } }),
|
||||
signInResolver: async () => ({ token: mockBackstageToken }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
agent.jar.setCookie(
|
||||
'my-provider-nonce=123',
|
||||
'127.0.0.1',
|
||||
'/my-provider/handler',
|
||||
);
|
||||
|
||||
mockAuthenticator.authenticate.mockResolvedValue({
|
||||
fullProfile: { id: 'id' } as PassportProfile,
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
const res = await agent.get('/my-provider/handler/frame').query({
|
||||
state: encodeOAuthState({
|
||||
env: 'development',
|
||||
nonce: '123',
|
||||
scope: 'my-scope',
|
||||
flow: 'redirect',
|
||||
redirectUrl: 'https://127.0.0.1:3000/redirect',
|
||||
} as OAuthState),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.get('Location')).toBe('https://127.0.0.1:3000/redirect');
|
||||
|
||||
expect(getRefreshTokenCookie(agent).value).toBe('refresh-token');
|
||||
expect(getGrantedScopesCookie(agent).value).toBe('my-scope');
|
||||
});
|
||||
|
||||
it('should require a valid origin', async () => {
|
||||
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
|
||||
const res = await request(app)
|
||||
.get('/my-provider/handler/frame')
|
||||
.query({
|
||||
state: encodeOAuthState({
|
||||
env: 'development',
|
||||
nonce: '123',
|
||||
origin: 'invalid-origin',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(parseWebMessageResponse(res.text).response).toEqual({
|
||||
type: 'authorization_response',
|
||||
error: {
|
||||
name: 'NotAllowedError',
|
||||
message: 'App origin is invalid, failed to parse',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject origins that are not allowed', async () => {
|
||||
const app = wrapInApp(
|
||||
createOAuthRouteHandlers({
|
||||
...baseConfig,
|
||||
isOriginAllowed: () => false,
|
||||
}),
|
||||
);
|
||||
const res = await request(app)
|
||||
.get('/my-provider/handler/frame')
|
||||
.query({
|
||||
state: encodeOAuthState({
|
||||
env: 'development',
|
||||
nonce: '123',
|
||||
origin: 'http://localhost:3000',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(parseWebMessageResponse(res.text).response).toEqual({
|
||||
type: 'authorization_response',
|
||||
error: {
|
||||
name: 'NotAllowedError',
|
||||
message: "Origin 'http://localhost:3000' is not allowed",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject missing state env', async () => {
|
||||
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
|
||||
const res = await request(app)
|
||||
.get('/my-provider/handler/frame')
|
||||
.query({
|
||||
state: encodeOAuthState({
|
||||
nonce: '123',
|
||||
} as OAuthState),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(parseWebMessageResponse(res.text).response).toEqual({
|
||||
type: 'authorization_response',
|
||||
error: {
|
||||
name: 'NotAllowedError',
|
||||
message: 'OAuth state is invalid, missing env',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject missing cookie nonce', async () => {
|
||||
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
|
||||
const res = await request(app)
|
||||
.get('/my-provider/handler/frame')
|
||||
.query({
|
||||
state: encodeOAuthState({
|
||||
env: 'development',
|
||||
nonce: '123',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(parseWebMessageResponse(res.text).response).toEqual({
|
||||
type: 'authorization_response',
|
||||
error: {
|
||||
name: 'NotAllowedError',
|
||||
message: 'Auth response is missing cookie nonce',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject missing state nonce', async () => {
|
||||
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
|
||||
const res = await request(app)
|
||||
.get('/my-provider/handler/frame')
|
||||
.query({
|
||||
state: encodeOAuthState({
|
||||
env: 'development',
|
||||
} as OAuthState),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(parseWebMessageResponse(res.text).response).toEqual({
|
||||
type: 'authorization_response',
|
||||
error: {
|
||||
name: 'NotAllowedError',
|
||||
message: 'OAuth state is invalid, missing nonce',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject mismatched nonce', async () => {
|
||||
const agent = request.agent(
|
||||
wrapInApp(createOAuthRouteHandlers(baseConfig)),
|
||||
);
|
||||
|
||||
agent.jar.setCookie(
|
||||
'my-provider-nonce=456',
|
||||
'127.0.0.1',
|
||||
'/my-provider/handler',
|
||||
);
|
||||
|
||||
const res = await agent.get('/my-provider/handler/frame').query({
|
||||
state: encodeOAuthState({
|
||||
env: 'development',
|
||||
nonce: '123',
|
||||
} as OAuthState),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(parseWebMessageResponse(res.text).response).toEqual({
|
||||
type: 'authorization_response',
|
||||
error: {
|
||||
name: 'NotAllowedError',
|
||||
message: 'Invalid nonce',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('should refresh', async () => {
|
||||
const agent = request.agent(
|
||||
wrapInApp(createOAuthRouteHandlers(baseConfig)),
|
||||
);
|
||||
|
||||
agent.jar.setCookie(
|
||||
'my-provider-refresh-token=refresh-token',
|
||||
'127.0.0.1',
|
||||
'/my-provider',
|
||||
);
|
||||
|
||||
mockAuthenticator.refresh.mockImplementation(async ({ scope }) => ({
|
||||
fullProfile: { id: 'id' } as PassportProfile,
|
||||
session: { ...mockSession, scope },
|
||||
}));
|
||||
|
||||
const res = await agent
|
||||
.post('/my-provider/refresh')
|
||||
.set('X-Requested-With', 'XMLHttpRequest')
|
||||
.query({ scope: 'my-scope' });
|
||||
|
||||
expect(mockAuthenticator.refresh).toHaveBeenCalledWith(
|
||||
{
|
||||
req: expect.anything(),
|
||||
refreshToken: 'refresh-token',
|
||||
scope: 'my-scope',
|
||||
},
|
||||
{ ctx: 'authenticator' },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
profile: {},
|
||||
providerInfo: {
|
||||
accessToken: 'access-token',
|
||||
expiresInSeconds: 3,
|
||||
idToken: 'id-token',
|
||||
scope: 'my-scope',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should refresh with sign-in, profile transform, and persisted scopes', async () => {
|
||||
const agent = request.agent(
|
||||
wrapInApp(
|
||||
createOAuthRouteHandlers({
|
||||
...baseConfig,
|
||||
authenticator: {
|
||||
...mockAuthenticator,
|
||||
shouldPersistScopes: true,
|
||||
},
|
||||
profileTransform: async () => ({ profile: { email: 'em@i.l' } }),
|
||||
signInResolver: async () => ({ token: mockBackstageToken }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
agent.jar.setCookie(
|
||||
'my-provider-refresh-token=refresh-token',
|
||||
'127.0.0.1',
|
||||
'/my-provider',
|
||||
);
|
||||
agent.jar.setCookie(
|
||||
'my-provider-granted-scope=persisted-scope',
|
||||
'127.0.0.1',
|
||||
'/my-provider',
|
||||
);
|
||||
|
||||
mockAuthenticator.refresh.mockImplementation(async ({ scope }) => ({
|
||||
fullProfile: { id: 'id' } as PassportProfile,
|
||||
session: { ...mockSession, scope, refreshToken: 'new-refresh-token' },
|
||||
}));
|
||||
|
||||
const res = await agent
|
||||
.post('/my-provider/refresh')
|
||||
.set('X-Requested-With', 'XMLHttpRequest');
|
||||
|
||||
expect(mockAuthenticator.refresh).toHaveBeenCalledWith(
|
||||
{
|
||||
req: expect.anything(),
|
||||
refreshToken: 'refresh-token',
|
||||
scope: 'persisted-scope',
|
||||
},
|
||||
{ ctx: 'authenticator' },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
profile: { email: 'em@i.l' },
|
||||
providerInfo: {
|
||||
accessToken: 'access-token',
|
||||
expiresInSeconds: 3,
|
||||
idToken: 'id-token',
|
||||
scope: 'persisted-scope',
|
||||
},
|
||||
backstageIdentity: {
|
||||
identity: {
|
||||
type: 'user',
|
||||
ownershipEntityRefs: [],
|
||||
userEntityRef: 'user:default/mock',
|
||||
},
|
||||
token: mockBackstageToken,
|
||||
},
|
||||
});
|
||||
expect(getRefreshTokenCookie(agent).value).toBe('new-refresh-token');
|
||||
});
|
||||
|
||||
it('should forward errors', async () => {
|
||||
const agent = request.agent(
|
||||
wrapInApp(createOAuthRouteHandlers(baseConfig)),
|
||||
);
|
||||
|
||||
agent.jar.setCookie(
|
||||
'my-provider-refresh-token=refresh-token',
|
||||
'127.0.0.1',
|
||||
'/my-provider',
|
||||
);
|
||||
|
||||
mockAuthenticator.refresh.mockRejectedValue(new Error('NOPE'));
|
||||
|
||||
const res = await agent
|
||||
.post('/my-provider/refresh')
|
||||
.set('X-Requested-With', 'XMLHttpRequest');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toMatchObject({
|
||||
error: {
|
||||
name: 'AuthenticationError',
|
||||
message: 'Refresh failed; caused by Error: NOPE',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should require refresh cookie', async () => {
|
||||
const res = await request(wrapInApp(createOAuthRouteHandlers(baseConfig)))
|
||||
.post('/my-provider/refresh')
|
||||
.set('X-Requested-With', 'XMLHttpRequest');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toMatchObject({
|
||||
error: {
|
||||
name: 'AuthenticationError',
|
||||
message:
|
||||
'Refresh failed; caused by InputError: Missing session cookie',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject requests without CSRF header', async () => {
|
||||
const res = await request(
|
||||
wrapInApp(createOAuthRouteHandlers(baseConfig)),
|
||||
).post('/my-provider/refresh');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toMatchObject({
|
||||
error: {
|
||||
name: 'AuthenticationError',
|
||||
message: 'Invalid X-Requested-With header',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject requests with invalid CSRF header', async () => {
|
||||
const res = await request(wrapInApp(createOAuthRouteHandlers(baseConfig)))
|
||||
.post('/my-provider/refresh')
|
||||
.set('X-Requested-With', 'invalid');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toMatchObject({
|
||||
error: {
|
||||
name: 'AuthenticationError',
|
||||
message: 'Invalid X-Requested-With header',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should log out', async () => {
|
||||
const agent = request.agent(
|
||||
wrapInApp(createOAuthRouteHandlers(baseConfig)),
|
||||
);
|
||||
|
||||
agent.jar.setCookie(
|
||||
'my-provider-refresh-token=my-refresh-token',
|
||||
'127.0.0.1',
|
||||
'/my-provider',
|
||||
);
|
||||
|
||||
expect(getRefreshTokenCookie(agent).value).toBe('my-refresh-token');
|
||||
|
||||
const res = await agent
|
||||
.post('/my-provider/logout')
|
||||
.set('X-Requested-With', 'XMLHttpRequest');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({});
|
||||
|
||||
expect(getRefreshTokenCookie(agent)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject requests without CSRF header', async () => {
|
||||
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
|
||||
|
||||
const res = await request(app).post('/my-provider/logout');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toMatchObject({
|
||||
error: {
|
||||
name: 'AuthenticationError',
|
||||
message: 'Invalid X-Requested-With header',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject requests with invalid CSRF header', async () => {
|
||||
const app = wrapInApp(createOAuthRouteHandlers(baseConfig));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/my-provider/logout')
|
||||
.set('X-Requested-With', 'wrong-value');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toMatchObject({
|
||||
error: {
|
||||
name: 'AuthenticationError',
|
||||
message: 'Invalid X-Requested-With header',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,343 @@
|
||||
/*
|
||||
* 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 express from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { URL } from 'url';
|
||||
import {
|
||||
AuthenticationError,
|
||||
InputError,
|
||||
isError,
|
||||
NotAllowedError,
|
||||
} from '@backstage/errors';
|
||||
import {
|
||||
encodeOAuthState,
|
||||
decodeOAuthState,
|
||||
OAuthStateTransform,
|
||||
OAuthState,
|
||||
} from './state';
|
||||
import { sendWebMessageResponse } from '../flow';
|
||||
import { prepareBackstageIdentityResponse } from '../identity';
|
||||
import { OAuthCookieManager } from './OAuthCookieManager';
|
||||
import {
|
||||
AuthProviderRouteHandlers,
|
||||
AuthResolverContext,
|
||||
ClientAuthResponse,
|
||||
CookieConfigurer,
|
||||
ProfileTransform,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import { OAuthAuthenticator, OAuthAuthenticatorResult } from './types';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
/** @public */
|
||||
export interface OAuthRouteHandlersOptions<TProfile> {
|
||||
authenticator: OAuthAuthenticator<any, TProfile>;
|
||||
appUrl: string;
|
||||
baseUrl: string;
|
||||
isOriginAllowed: (origin: string) => boolean;
|
||||
providerId: string;
|
||||
config: Config;
|
||||
resolverContext: AuthResolverContext;
|
||||
stateTransform?: OAuthStateTransform;
|
||||
profileTransform?: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
|
||||
cookieConfigurer?: CookieConfigurer;
|
||||
signInResolver?: SignInResolver<OAuthAuthenticatorResult<TProfile>>;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
type ClientOAuthResponse = ClientAuthResponse<{
|
||||
/**
|
||||
* An access token issued for the signed in user.
|
||||
*/
|
||||
accessToken: string;
|
||||
/**
|
||||
* (Optional) Id token issued for the signed in user.
|
||||
*/
|
||||
idToken?: string;
|
||||
/**
|
||||
* Expiry of the access token in seconds.
|
||||
*/
|
||||
expiresInSeconds?: number;
|
||||
/**
|
||||
* Scopes granted for the access token.
|
||||
*/
|
||||
scope: string;
|
||||
}>;
|
||||
|
||||
/** @public */
|
||||
export function createOAuthRouteHandlers<TProfile>(
|
||||
options: OAuthRouteHandlersOptions<TProfile>,
|
||||
): AuthProviderRouteHandlers {
|
||||
const {
|
||||
authenticator,
|
||||
config,
|
||||
baseUrl,
|
||||
appUrl,
|
||||
providerId,
|
||||
isOriginAllowed,
|
||||
cookieConfigurer,
|
||||
resolverContext,
|
||||
signInResolver,
|
||||
} = options;
|
||||
|
||||
const defaultAppOrigin = new URL(appUrl).origin;
|
||||
const callbackUrl =
|
||||
config.getOptionalString('callbackUrl') ??
|
||||
`${baseUrl}/${providerId}/handler/frame`;
|
||||
|
||||
const stateTransform = options.stateTransform ?? (state => ({ state }));
|
||||
const profileTransform =
|
||||
options.profileTransform ?? authenticator.defaultProfileTransform;
|
||||
const authenticatorCtx = authenticator.initialize({ config, callbackUrl });
|
||||
const cookieManager = new OAuthCookieManager({
|
||||
baseUrl,
|
||||
callbackUrl,
|
||||
defaultAppOrigin,
|
||||
providerId,
|
||||
cookieConfigurer,
|
||||
});
|
||||
|
||||
return {
|
||||
async start(
|
||||
this: never,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<void> {
|
||||
// retrieve scopes from request
|
||||
const scope = req.query.scope?.toString() ?? '';
|
||||
const env = req.query.env?.toString();
|
||||
const origin = req.query.origin?.toString();
|
||||
const redirectUrl = req.query.redirectUrl?.toString();
|
||||
const flow = req.query.flow?.toString();
|
||||
|
||||
if (!env) {
|
||||
throw new InputError('No env provided in request query parameters');
|
||||
}
|
||||
|
||||
const nonce = crypto.randomBytes(16).toString('base64');
|
||||
// set a nonce cookie before redirecting to oauth provider
|
||||
cookieManager.setNonce(res, nonce, origin);
|
||||
|
||||
const state: OAuthState = { nonce, env, origin, redirectUrl, flow };
|
||||
|
||||
// If scopes are persisted then we pass them through the state so that we
|
||||
// can set the cookie on successful auth
|
||||
if (authenticator.shouldPersistScopes) {
|
||||
state.scope = scope;
|
||||
}
|
||||
|
||||
const { state: transformedState } = await stateTransform(state, { req });
|
||||
const encodedState = encodeOAuthState(transformedState);
|
||||
|
||||
const { url, status } = await options.authenticator.start(
|
||||
{ req, scope, state: encodedState },
|
||||
authenticatorCtx,
|
||||
);
|
||||
|
||||
res.statusCode = status || 302;
|
||||
res.setHeader('Location', url);
|
||||
res.setHeader('Content-Length', '0');
|
||||
res.end();
|
||||
},
|
||||
|
||||
async frameHandler(
|
||||
this: never,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<void> {
|
||||
let appOrigin = defaultAppOrigin;
|
||||
|
||||
try {
|
||||
const state = decodeOAuthState(req.query.state?.toString() ?? '');
|
||||
|
||||
if (state.origin) {
|
||||
try {
|
||||
appOrigin = new URL(state.origin).origin;
|
||||
} catch {
|
||||
throw new NotAllowedError('App origin is invalid, failed to parse');
|
||||
}
|
||||
if (!isOriginAllowed(appOrigin)) {
|
||||
throw new NotAllowedError(`Origin '${appOrigin}' is not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
// The same nonce is passed through cookie and state, and they must match
|
||||
const cookieNonce = cookieManager.getNonce(req);
|
||||
const stateNonce = state.nonce;
|
||||
if (!cookieNonce) {
|
||||
throw new NotAllowedError('Auth response is missing cookie nonce');
|
||||
}
|
||||
if (cookieNonce !== stateNonce) {
|
||||
throw new NotAllowedError('Invalid nonce');
|
||||
}
|
||||
|
||||
const result = await authenticator.authenticate(
|
||||
{ req },
|
||||
authenticatorCtx,
|
||||
);
|
||||
const { profile } = await profileTransform(result, resolverContext);
|
||||
|
||||
const response: ClientOAuthResponse = {
|
||||
profile,
|
||||
providerInfo: {
|
||||
idToken: result.session.idToken,
|
||||
accessToken: result.session.accessToken,
|
||||
scope: result.session.scope,
|
||||
expiresInSeconds: result.session.expiresInSeconds,
|
||||
},
|
||||
};
|
||||
|
||||
if (signInResolver) {
|
||||
const identity = await signInResolver(
|
||||
{ profile, result },
|
||||
resolverContext,
|
||||
);
|
||||
response.backstageIdentity =
|
||||
prepareBackstageIdentityResponse(identity);
|
||||
}
|
||||
|
||||
// Store the scope that we have been granted for this session. This is useful if
|
||||
// the provider does not return granted scopes on refresh or if they are normalized.
|
||||
if (authenticator.shouldPersistScopes && state.scope) {
|
||||
cookieManager.setGrantedScopes(res, state.scope, appOrigin);
|
||||
result.session.scope = state.scope;
|
||||
}
|
||||
|
||||
if (result.session.refreshToken) {
|
||||
// set new refresh token
|
||||
cookieManager.setRefreshToken(
|
||||
res,
|
||||
result.session.refreshToken,
|
||||
appOrigin,
|
||||
);
|
||||
}
|
||||
|
||||
// When using the redirect flow we rely on refresh token we just
|
||||
// acquired to get a new session once we're back in the app.
|
||||
if (state.flow === 'redirect') {
|
||||
if (!state.redirectUrl) {
|
||||
throw new InputError(
|
||||
'No redirectUrl provided in request query parameters',
|
||||
);
|
||||
}
|
||||
res.redirect(state.redirectUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// post message back to popup if successful
|
||||
sendWebMessageResponse(res, appOrigin, {
|
||||
type: 'authorization_response',
|
||||
response,
|
||||
});
|
||||
} catch (error) {
|
||||
const { name, message } = isError(error)
|
||||
? error
|
||||
: new Error('Encountered invalid error'); // Being a bit safe and not forwarding the bad value
|
||||
// post error message back to popup if failure
|
||||
sendWebMessageResponse(res, appOrigin, {
|
||||
type: 'authorization_response',
|
||||
error: { name, message },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async logout(
|
||||
this: never,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<void> {
|
||||
// We use this as a lightweight CSRF protection
|
||||
if (req.header('X-Requested-With') !== 'XMLHttpRequest') {
|
||||
throw new AuthenticationError('Invalid X-Requested-With header');
|
||||
}
|
||||
|
||||
if (authenticator.logout) {
|
||||
const refreshToken = cookieManager.getRefreshToken(req);
|
||||
await authenticator.logout({ req, refreshToken }, authenticatorCtx);
|
||||
}
|
||||
|
||||
// remove refresh token cookie if it is set
|
||||
cookieManager.removeRefreshToken(res, req.get('origin'));
|
||||
|
||||
res.status(200).end();
|
||||
},
|
||||
|
||||
async refresh(
|
||||
this: never,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<void> {
|
||||
// We use this as a lightweight CSRF protection
|
||||
if (req.header('X-Requested-With') !== 'XMLHttpRequest') {
|
||||
throw new AuthenticationError('Invalid X-Requested-With header');
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshToken = cookieManager.getRefreshToken(req);
|
||||
|
||||
// throw error if refresh token is missing in the request
|
||||
if (!refreshToken) {
|
||||
throw new InputError('Missing session cookie');
|
||||
}
|
||||
|
||||
let scope = req.query.scope?.toString() ?? '';
|
||||
if (authenticator.shouldPersistScopes) {
|
||||
scope = cookieManager.getGrantedScopes(req);
|
||||
}
|
||||
|
||||
const result = await authenticator.refresh(
|
||||
{ req, scope, refreshToken },
|
||||
authenticatorCtx,
|
||||
);
|
||||
|
||||
const { profile } = await profileTransform(result, resolverContext);
|
||||
|
||||
const newRefreshToken = result.session.refreshToken;
|
||||
if (newRefreshToken && newRefreshToken !== refreshToken) {
|
||||
cookieManager.setRefreshToken(
|
||||
res,
|
||||
newRefreshToken,
|
||||
req.get('origin'),
|
||||
);
|
||||
}
|
||||
|
||||
const response: ClientOAuthResponse = {
|
||||
profile,
|
||||
providerInfo: {
|
||||
idToken: result.session.idToken,
|
||||
accessToken: result.session.accessToken,
|
||||
scope: result.session.scope,
|
||||
expiresInSeconds: result.session.expiresInSeconds,
|
||||
},
|
||||
};
|
||||
|
||||
if (signInResolver) {
|
||||
const identity = await signInResolver(
|
||||
{ profile, result },
|
||||
resolverContext,
|
||||
);
|
||||
response.backstageIdentity =
|
||||
prepareBackstageIdentityResponse(identity);
|
||||
}
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
throw new AuthenticationError('Refresh failed', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
createOAuthRouteHandlers,
|
||||
type OAuthRouteHandlersOptions,
|
||||
} from './createOAuthRouteHandlers';
|
||||
export {
|
||||
PassportOAuthAuthenticatorHelper,
|
||||
type PassportOAuthDoneCallback,
|
||||
type PassportOAuthPrivateInfo,
|
||||
type PassportOAuthResult,
|
||||
} from './PassportOAuthAuthenticatorHelper';
|
||||
export { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler';
|
||||
export { createOAuthProviderFactory } from './createOAuthProviderFactory';
|
||||
export {
|
||||
encodeOAuthState,
|
||||
decodeOAuthState,
|
||||
type OAuthState,
|
||||
type OAuthStateTransform,
|
||||
} from './state';
|
||||
export {
|
||||
createOAuthAuthenticator,
|
||||
type OAuthAuthenticator,
|
||||
type OAuthAuthenticatorAuthenticateInput,
|
||||
type OAuthAuthenticatorLogoutInput,
|
||||
type OAuthAuthenticatorRefreshInput,
|
||||
type OAuthAuthenticatorResult,
|
||||
type OAuthAuthenticatorStartInput,
|
||||
type OAuthSession,
|
||||
} from './types';
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2023 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 pickBy from 'lodash/pickBy';
|
||||
import { Request } from 'express';
|
||||
import { NotAllowedError } from '@backstage/errors';
|
||||
|
||||
/**
|
||||
* A type for the serialized value in the `state` parameter of the OAuth authorization flow
|
||||
* @public
|
||||
*/
|
||||
export type OAuthState = {
|
||||
nonce: string;
|
||||
env: string;
|
||||
origin?: string;
|
||||
scope?: string;
|
||||
redirectUrl?: string;
|
||||
flow?: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type OAuthStateTransform = (
|
||||
state: OAuthState,
|
||||
context: { req: Request },
|
||||
) => Promise<{ state: OAuthState }>;
|
||||
|
||||
/** @public */
|
||||
export function encodeOAuthState(state: OAuthState): string {
|
||||
const stateString = new URLSearchParams(
|
||||
pickBy<string>(state, value => value !== undefined),
|
||||
).toString();
|
||||
|
||||
return Buffer.from(stateString, 'utf-8').toString('hex');
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function decodeOAuthState(encodedState: string): OAuthState {
|
||||
const state = Object.fromEntries(
|
||||
new URLSearchParams(Buffer.from(encodedState, 'hex').toString('utf-8')),
|
||||
);
|
||||
if (!state.env || state.env?.length === 0) {
|
||||
throw new NotAllowedError('OAuth state is invalid, missing env');
|
||||
}
|
||||
if (!state.nonce || state.nonce?.length === 0) {
|
||||
throw new NotAllowedError('OAuth state is invalid, missing nonce');
|
||||
}
|
||||
|
||||
return state as OAuthState;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Config } from '@backstage/config';
|
||||
import { Request } from 'express';
|
||||
import { ProfileTransform } from '../types';
|
||||
|
||||
/** @public */
|
||||
export interface OAuthSession {
|
||||
accessToken: string;
|
||||
tokenType: string;
|
||||
idToken?: string;
|
||||
scope: string;
|
||||
expiresInSeconds: number;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface OAuthAuthenticatorStartInput {
|
||||
scope: string;
|
||||
state: string;
|
||||
req: Request;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface OAuthAuthenticatorAuthenticateInput {
|
||||
req: Request;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface OAuthAuthenticatorRefreshInput {
|
||||
scope: string;
|
||||
refreshToken: string;
|
||||
req: Request;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface OAuthAuthenticatorLogoutInput {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
req: Request;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface OAuthAuthenticatorResult<TProfile> {
|
||||
fullProfile: TProfile;
|
||||
session: OAuthSession;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface OAuthAuthenticator<TContext, TProfile> {
|
||||
defaultProfileTransform: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
|
||||
shouldPersistScopes?: boolean;
|
||||
initialize(ctx: { callbackUrl: string; config: Config }): TContext;
|
||||
start(
|
||||
input: OAuthAuthenticatorStartInput,
|
||||
ctx: TContext,
|
||||
): Promise<{ url: string; status?: number }>;
|
||||
authenticate(
|
||||
input: OAuthAuthenticatorAuthenticateInput,
|
||||
ctx: TContext,
|
||||
): Promise<OAuthAuthenticatorResult<TProfile>>;
|
||||
refresh(
|
||||
input: OAuthAuthenticatorRefreshInput,
|
||||
ctx: TContext,
|
||||
): Promise<OAuthAuthenticatorResult<TProfile>>;
|
||||
logout?(input: OAuthAuthenticatorLogoutInput, ctx: TContext): Promise<void>;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function createOAuthAuthenticator<TContext, TProfile>(
|
||||
authenticator: OAuthAuthenticator<TContext, TProfile>,
|
||||
): OAuthAuthenticator<TContext, TProfile> {
|
||||
return authenticator;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Request } from 'express';
|
||||
import { Strategy } from 'passport';
|
||||
import { PassportProfile } from './types';
|
||||
import { ProfileInfo } from '../types';
|
||||
|
||||
// Re-declared here to avoid direct dependency on passport-oauth2
|
||||
/** @internal */
|
||||
interface InternalOAuthError extends Error {
|
||||
oauthError?: {
|
||||
data?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
function decodeJwtPayload(token: string): Record<string, string> {
|
||||
const payloadStr = token.split('.')[1];
|
||||
if (!payloadStr) {
|
||||
throw new Error('Invalid JWT token');
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(
|
||||
Buffer.from(
|
||||
payloadStr.replace(/-/g, '+').replace(/_/g, '/'),
|
||||
'base64',
|
||||
).toString('utf8'),
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error('Invalid JWT token');
|
||||
}
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new Error('Invalid JWT token');
|
||||
}
|
||||
return payload as Record<string, string>;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export class PassportHelpers {
|
||||
private constructor() {}
|
||||
|
||||
static transformProfile = (
|
||||
profile: PassportProfile,
|
||||
idToken?: string,
|
||||
): ProfileInfo => {
|
||||
let email: string | undefined = undefined;
|
||||
if (profile.emails && profile.emails.length > 0) {
|
||||
const [firstEmail] = profile.emails;
|
||||
email = firstEmail.value;
|
||||
}
|
||||
|
||||
let picture: string | undefined = undefined;
|
||||
if (profile.avatarUrl) {
|
||||
picture = profile.avatarUrl;
|
||||
} else if (profile.photos && profile.photos.length > 0) {
|
||||
const [firstPhoto] = profile.photos;
|
||||
picture = firstPhoto.value;
|
||||
}
|
||||
|
||||
let displayName: string | undefined =
|
||||
profile.displayName ?? profile.username ?? profile.id;
|
||||
|
||||
if ((!email || !picture || !displayName) && idToken) {
|
||||
try {
|
||||
const decoded: Record<string, string> = decodeJwtPayload(idToken);
|
||||
if (!email && decoded.email) {
|
||||
email = decoded.email;
|
||||
}
|
||||
if (!picture && decoded.picture) {
|
||||
picture = decoded.picture;
|
||||
}
|
||||
if (!displayName && decoded.name) {
|
||||
displayName = decoded.name;
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to parse id token and get profile info, ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
picture,
|
||||
displayName,
|
||||
};
|
||||
};
|
||||
|
||||
static async executeRedirectStrategy(
|
||||
req: Request,
|
||||
providerStrategy: Strategy,
|
||||
options: Record<string, string>,
|
||||
): Promise<{
|
||||
/**
|
||||
* URL to redirect to
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Status code to use for the redirect
|
||||
*/
|
||||
status?: number;
|
||||
}> {
|
||||
return new Promise(resolve => {
|
||||
const strategy = Object.create(providerStrategy);
|
||||
strategy.redirect = (url: string, status?: number) => {
|
||||
resolve({ url, status: status ?? undefined });
|
||||
};
|
||||
|
||||
strategy.authenticate(req, { ...options });
|
||||
});
|
||||
}
|
||||
|
||||
static async executeFrameHandlerStrategy<TResult, TPrivateInfo = never>(
|
||||
req: Request,
|
||||
providerStrategy: Strategy,
|
||||
options?: Record<string, string>,
|
||||
): Promise<{ result: TResult; privateInfo: TPrivateInfo }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const strategy = Object.create(providerStrategy);
|
||||
strategy.success = (result: any, privateInfo: any) => {
|
||||
resolve({ result, privateInfo });
|
||||
};
|
||||
strategy.fail = (
|
||||
info: { type: 'success' | 'error'; message?: string },
|
||||
// _status: number,
|
||||
) => {
|
||||
reject(new Error(`Authentication rejected, ${info.message ?? ''}`));
|
||||
};
|
||||
strategy.error = (error: InternalOAuthError) => {
|
||||
let message = `Authentication failed, ${error.message}`;
|
||||
|
||||
if (error.oauthError?.data) {
|
||||
try {
|
||||
const errorData = JSON.parse(error.oauthError.data);
|
||||
|
||||
if (errorData.message) {
|
||||
message += ` - ${errorData.message}`;
|
||||
}
|
||||
} catch (parseError) {
|
||||
message += ` - ${error.oauthError}`;
|
||||
}
|
||||
}
|
||||
|
||||
reject(new Error(message));
|
||||
};
|
||||
strategy.redirect = () => {
|
||||
reject(new Error('Unexpected redirect'));
|
||||
};
|
||||
strategy.authenticate(req, { ...(options ?? {}) });
|
||||
});
|
||||
}
|
||||
|
||||
static async executeRefreshTokenStrategy(
|
||||
providerStrategy: Strategy,
|
||||
refreshToken: string,
|
||||
scope: string,
|
||||
): Promise<{
|
||||
/**
|
||||
* An access token issued for the signed in user.
|
||||
*/
|
||||
accessToken: string;
|
||||
/**
|
||||
* Optionally, the server can issue a new Refresh Token for the user
|
||||
*/
|
||||
refreshToken?: string;
|
||||
params: any;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const anyStrategy = providerStrategy as any;
|
||||
const OAuth2 = anyStrategy._oauth2.constructor;
|
||||
const oauth2 = new OAuth2(
|
||||
anyStrategy._oauth2._clientId,
|
||||
anyStrategy._oauth2._clientSecret,
|
||||
anyStrategy._oauth2._baseSite,
|
||||
anyStrategy._oauth2._authorizeUrl,
|
||||
anyStrategy._refreshURL || anyStrategy._oauth2._accessTokenUrl,
|
||||
anyStrategy._oauth2._customHeaders,
|
||||
);
|
||||
|
||||
oauth2.getOAuthAccessToken(
|
||||
refreshToken,
|
||||
{
|
||||
scope,
|
||||
grant_type: 'refresh_token',
|
||||
},
|
||||
(
|
||||
err: Error | null,
|
||||
accessToken: string,
|
||||
newRefreshToken: string,
|
||||
params: any,
|
||||
) => {
|
||||
if (err) {
|
||||
reject(
|
||||
new Error(`Failed to refresh access token ${err.toString()}`),
|
||||
);
|
||||
}
|
||||
if (!accessToken) {
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to refresh access token, no access token received`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
resolve({
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
params,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static async executeFetchUserProfileStrategy(
|
||||
providerStrategy: Strategy,
|
||||
accessToken: string,
|
||||
): Promise<PassportProfile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const anyStrategy = providerStrategy as unknown as {
|
||||
userProfile(accessToken: string, callback: Function): void;
|
||||
};
|
||||
anyStrategy.userProfile(
|
||||
accessToken,
|
||||
(error: Error, rawProfile: PassportProfile) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(rawProfile);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2023 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 { PassportHelpers } from './PassportHelpers';
|
||||
export type { PassportDoneCallback, PassportProfile } from './types';
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Profile } from 'passport';
|
||||
|
||||
/** @public */
|
||||
export type PassportProfile = Profile & {
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export type PassportDoneCallback<TResult, TPrivateInfo = never> = (
|
||||
err?: Error,
|
||||
result?: TResult,
|
||||
privateInfo?: TPrivateInfo,
|
||||
) => void;
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2023 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 {
|
||||
readDeclarativeSignInResolver,
|
||||
SignInResolverFactory,
|
||||
} from '../sign-in';
|
||||
import {
|
||||
AuthProviderFactory,
|
||||
ProfileTransform,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import { createProxyAuthRouteHandlers } from './createProxyRouteHandlers';
|
||||
import { ProxyAuthenticator } from './types';
|
||||
|
||||
/** @public */
|
||||
export function createProxyAuthProviderFactory<TResult>(options: {
|
||||
authenticator: ProxyAuthenticator<unknown, TResult>;
|
||||
profileTransform?: ProfileTransform<TResult>;
|
||||
signInResolver?: SignInResolver<TResult>;
|
||||
signInResolverFactories?: Record<
|
||||
string,
|
||||
SignInResolverFactory<TResult, unknown>
|
||||
>;
|
||||
}): AuthProviderFactory {
|
||||
return ctx => {
|
||||
const signInResolver =
|
||||
options.signInResolver ??
|
||||
readDeclarativeSignInResolver({
|
||||
config: ctx.config,
|
||||
signInResolverFactories: options.signInResolverFactories ?? {},
|
||||
});
|
||||
|
||||
if (!signInResolver) {
|
||||
throw new Error(
|
||||
`No sign-in resolver configured for proxy auth provider '${ctx.providerId}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return createProxyAuthRouteHandlers<TResult>({
|
||||
signInResolver,
|
||||
config: ctx.config,
|
||||
authenticator: options.authenticator,
|
||||
resolverContext: ctx.resolverContext,
|
||||
profileTransform: options.profileTransform,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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 { Request, Response } from 'express';
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
AuthProviderRouteHandlers,
|
||||
AuthResolverContext,
|
||||
ClientAuthResponse,
|
||||
ProfileTransform,
|
||||
SignInResolver,
|
||||
} from '../types';
|
||||
import { ProxyAuthenticator } from './types';
|
||||
import { prepareBackstageIdentityResponse } from '../identity';
|
||||
import { NotImplementedError } from '@backstage/errors';
|
||||
|
||||
/** @public */
|
||||
export interface ProxyAuthRouteHandlersOptions<TResult> {
|
||||
authenticator: ProxyAuthenticator<any, TResult>;
|
||||
config: Config;
|
||||
resolverContext: AuthResolverContext;
|
||||
signInResolver: SignInResolver<TResult>;
|
||||
profileTransform?: ProfileTransform<TResult>;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function createProxyAuthRouteHandlers<TResult>(
|
||||
options: ProxyAuthRouteHandlersOptions<TResult>,
|
||||
): AuthProviderRouteHandlers {
|
||||
const { authenticator, config, resolverContext, signInResolver } = options;
|
||||
|
||||
const profileTransform =
|
||||
options.profileTransform ?? authenticator.defaultProfileTransform;
|
||||
const authenticatorCtx = authenticator.initialize({ config });
|
||||
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
throw new NotImplementedError('Not implemented');
|
||||
},
|
||||
|
||||
async frameHandler(): Promise<void> {
|
||||
throw new NotImplementedError('Not implemented');
|
||||
},
|
||||
|
||||
async refresh(this: never, req: Request, res: Response): Promise<void> {
|
||||
const { result } = await authenticator.authenticate(
|
||||
{ req },
|
||||
authenticatorCtx,
|
||||
);
|
||||
|
||||
const { profile } = await profileTransform(result, resolverContext);
|
||||
|
||||
const identity = await signInResolver(
|
||||
{ profile, result },
|
||||
resolverContext,
|
||||
);
|
||||
|
||||
const response: ClientAuthResponse<{}> = {
|
||||
profile,
|
||||
providerInfo: {},
|
||||
backstageIdentity: prepareBackstageIdentityResponse(identity),
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2023 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 { createProxyAuthenticator, type ProxyAuthenticator } from './types';
|
||||
export { createProxyAuthProviderFactory } from './createProxyAuthProviderFactory';
|
||||
export {
|
||||
createProxyAuthRouteHandlers,
|
||||
type ProxyAuthRouteHandlersOptions,
|
||||
} from './createProxyRouteHandlers';
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Config } from '@backstage/config';
|
||||
import { Request } from 'express';
|
||||
import { ProfileTransform } from '../types';
|
||||
|
||||
/** @public */
|
||||
export interface ProxyAuthenticator<TContext, TResult> {
|
||||
defaultProfileTransform: ProfileTransform<TResult>;
|
||||
initialize(ctx: { config: Config }): Promise<TContext>;
|
||||
authenticate(
|
||||
options: { req: Request },
|
||||
ctx: TContext,
|
||||
): Promise<{ result: TResult }>;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function createProxyAuthenticator<TContext, TResult>(
|
||||
authenticator: ProxyAuthenticator<TContext, TResult>,
|
||||
): ProxyAuthenticator<TContext, TResult> {
|
||||
return authenticator;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2023 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 { createSignInResolverFactory } from './createSignInResolverFactory';
|
||||
|
||||
/**
|
||||
* A collection of common sign-in resolvers that work with any auth provider.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export namespace commonSignInResolvers {
|
||||
/**
|
||||
* A common sign-in resolver that looks up the user using their email address
|
||||
* as email of the entity.
|
||||
*/
|
||||
export const emailMatchingUserEntityProfileEmail =
|
||||
createSignInResolverFactory({
|
||||
create() {
|
||||
return async (info, ctx) => {
|
||||
const { profile } = info;
|
||||
|
||||
if (!profile.email) {
|
||||
throw new Error(
|
||||
'Login failed, user profile does not contain an email',
|
||||
);
|
||||
}
|
||||
|
||||
return ctx.signInWithCatalogUser({
|
||||
filter: {
|
||||
'spec.profile.email': profile.email,
|
||||
},
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A common sign-in resolver that looks up the user using the local part of
|
||||
* their email address as the entity name.
|
||||
*/
|
||||
export const emailLocalPartMatchingUserEntityName =
|
||||
createSignInResolverFactory({
|
||||
create() {
|
||||
return async (info, ctx) => {
|
||||
const { profile } = info;
|
||||
|
||||
if (!profile.email) {
|
||||
throw new Error(
|
||||
'Login failed, user profile does not contain an email',
|
||||
);
|
||||
}
|
||||
const [localPart] = profile.email.split('@');
|
||||
|
||||
return ctx.signInWithCatalogUser({
|
||||
entityRef: { name: localPart },
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2023 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 { ZodSchema, ZodTypeDef } from 'zod';
|
||||
import { SignInResolver } from '../types';
|
||||
import zodToJsonSchema from 'zod-to-json-schema';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { InputError } from '@backstage/errors';
|
||||
|
||||
/** @public */
|
||||
export interface SignInResolverFactory<TAuthResult, TOptions> {
|
||||
(
|
||||
...options: undefined extends TOptions
|
||||
? [options?: TOptions]
|
||||
: [options: TOptions]
|
||||
): SignInResolver<TAuthResult>;
|
||||
optionsJsonSchema?: JsonObject;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface SignInResolverFactoryOptions<
|
||||
TAuthResult,
|
||||
TOptionsOutput,
|
||||
TOptionsInput,
|
||||
> {
|
||||
optionsSchema?: ZodSchema<TOptionsOutput, ZodTypeDef, TOptionsInput>;
|
||||
create(options: TOptionsOutput): SignInResolver<TAuthResult>;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function createSignInResolverFactory<
|
||||
TAuthResult,
|
||||
TOptionsOutput,
|
||||
TOptionsInput,
|
||||
>(
|
||||
options: SignInResolverFactoryOptions<
|
||||
TAuthResult,
|
||||
TOptionsOutput,
|
||||
TOptionsInput
|
||||
>,
|
||||
): SignInResolverFactory<TAuthResult, TOptionsInput> {
|
||||
const { optionsSchema } = options;
|
||||
if (!optionsSchema) {
|
||||
return (resolverOptions?: TOptionsInput) => {
|
||||
if (resolverOptions) {
|
||||
throw new InputError('sign-in resolver does not accept options');
|
||||
}
|
||||
return options.create(undefined as TOptionsOutput);
|
||||
};
|
||||
}
|
||||
const factory = (
|
||||
...[resolverOptions]: undefined extends TOptionsInput
|
||||
? [options?: TOptionsInput]
|
||||
: [options: TOptionsInput]
|
||||
) => {
|
||||
const parsedOptions = optionsSchema.parse(resolverOptions);
|
||||
return options.create(parsedOptions);
|
||||
};
|
||||
|
||||
factory.optionsJsonSchema = zodToJsonSchema(optionsSchema) as JsonObject;
|
||||
return factory;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user