From 863cb026b006fd4b276c0d65693a99fd1726c11d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 14 May 2023 12:59:51 +0200 Subject: [PATCH 01/66] auth-backend: replace Logger with LoggerService Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/package.json | 1 + plugins/auth-backend/src/identity/FirestoreKeyStore.ts | 4 ++-- plugins/auth-backend/src/identity/KeyStores.ts | 4 ++-- plugins/auth-backend/src/identity/TokenFactory.ts | 6 +++--- .../auth-backend/src/lib/catalog/CatalogIdentityClient.ts | 4 ++-- .../src/lib/resolvers/CatalogAuthResolverContext.ts | 6 +++--- plugins/auth-backend/src/providers/microsoft/provider.ts | 6 +++--- .../src/providers/oauth2-proxy/provider.test.ts | 6 +++--- plugins/auth-backend/src/providers/types.ts | 4 ++-- plugins/auth-backend/src/service/router.ts | 4 ++-- plugins/auth-backend/src/service/standaloneServer.ts | 4 ++-- yarn.lock | 1 + 12 files changed, 26 insertions(+), 24 deletions(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 1ad8d6c0c0..6bbf6c901c 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", diff --git a/plugins/auth-backend/src/identity/FirestoreKeyStore.ts b/plugins/auth-backend/src/identity/FirestoreKeyStore.ts index a5479a6470..b5f4f5dcfb 100644 --- a/plugins/auth-backend/src/identity/FirestoreKeyStore.ts +++ b/plugins/auth-backend/src/identity/FirestoreKeyStore.ts @@ -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 { try { await keyStore.verify(); diff --git a/plugins/auth-backend/src/identity/KeyStores.ts b/plugins/auth-backend/src/identity/KeyStores.ts index 4cd8f91842..cc35c0d84e 100644 --- a/plugins/auth-backend/src/identity/KeyStores.ts +++ b/plugins/auth-backend/src/identity/KeyStores.ts @@ -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; }; diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index f80b79b6bc..ca16f8821f 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -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; diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts index 494e8b4d38..ff983e27a5 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts @@ -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 { const { entityRefs, logger } = query; const resolvedEntityRefs = entityRefs diff --git a/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts b/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts index cbc2439563..7d22526193 100644 --- a/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts +++ b/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts @@ -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, diff --git a/plugins/auth-backend/src/providers/microsoft/provider.ts b/plugins/auth-backend/src/providers/microsoft/provider.ts index bc581f6d10..8fc8459f10 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.ts @@ -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; authHandler: AuthHandler; - 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; private readonly authHandler: AuthHandler; - private readonly logger: Logger; + private readonly logger: LoggerService; private readonly resolverContext: AuthResolverContext; constructor(options: Options) { diff --git a/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts b/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts index 0279604f2d..f585d4831f 100644 --- a/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts +++ b/plugins/auth-backend/src/providers/oauth2-proxy/provider.test.ts @@ -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; - let logger: jest.Mocked; + let logger: jest.Mocked; let signInResolver: jest.MockedFunction< SignInResolver> >; @@ -53,7 +53,7 @@ describe('Oauth2ProxyAuthProvider', () => { >; authHandler = jest.fn(); signInResolver = jest.fn(); - logger = { error: jest.fn() } as unknown as jest.Mocked; + logger = { error: jest.fn() } as unknown as jest.Mocked; mockResponse = { status: jest.fn(), diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 1459cff484..cba4ee1ee4 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -22,7 +22,7 @@ import { BackstageSignInResult, } from '@backstage/plugin-auth-node'; import express from 'express'; -import { Logger } from 'winston'; +import { LoggerService } from '@backstage/backend-plugin-api'; import { TokenParams } from '../identity/types'; import { OAuthStartRequest } from '../lib/oauth/types'; @@ -207,7 +207,7 @@ export type AuthProviderFactory = (options: { providerId: string; globalConfig: AuthProviderConfig; config: Config; - logger: Logger; + logger: LoggerService; resolverContext: AuthResolverContext; }) => AuthProviderRouteHandlers; diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index fa8dd95727..b28d2aeca1 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -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; diff --git a/plugins/auth-backend/src/service/standaloneServer.ts b/plugins/auth-backend/src/service/standaloneServer.ts index 16ebbc9345..abda3a341c 100644 --- a/plugins/auth-backend/src/service/standaloneServer.ts +++ b/plugins/auth-backend/src/service/standaloneServer.ts @@ -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( diff --git a/yarn.lock b/yarn.lock index fd36fd1910..cecd8afe31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4581,6 +4581,7 @@ __metadata: resolution: "@backstage/plugin-auth-backend@workspace:plugins/auth-backend" dependencies: "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-test-utils": "workspace:^" "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" From e19e04eab0f161066c52b11e2624362dc98c8653 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 13 Jul 2023 21:56:04 +0200 Subject: [PATCH 02/66] auth-backend: diagram for new architecture Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/architecture.drawio.svg | 262 +++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 plugins/auth-backend/architecture.drawio.svg diff --git a/plugins/auth-backend/architecture.drawio.svg b/plugins/auth-backend/architecture.drawio.svg new file mode 100644 index 0000000000..d709935e3e --- /dev/null +++ b/plugins/auth-backend/architecture.drawio.svg @@ -0,0 +1,262 @@ + + + + + + + +
+
+
+ PassportStrategy +
+
+
+
+ + PassportStrategy + +
+
+ + + + + + + + +
+
+
+ OAuthAuthenticator +
+
+
+
+ + OAuthAuthenticator + +
+
+ + + + + + + + + + + + +
+
+
+ ProxyProviderFactory +
+
+
+
+ + ProxyProviderFactory + +
+
+ + + + + + + + + + + + + + + + +
+
+
+ OAuthProviderFactory +
+
+
+
+ + OAuthProviderFactory + +
+
+ + + + +
+
+
+ ProfileTransform +
+
+
+
+ + ProfileTransform + +
+
+ + + + +
+
+
+ ProxyAuthenticator +
+
+
+
+ + ProxyAuthenticator + +
+
+ + + + +
+
+
+ AccessCheck +
+
+
+
+ + AccessCheck + +
+
+ + + + +
+
+
+ SignInResolver +
+
+
+
+ + SignInResolver + +
+
+ + + + + + +
+
+
+ authModule*Provider +
+
+
+
+ + authModule*Provider + +
+
+ + + + +
+
+
+ OAuthEnvironmentHandler +
+
+
+
+ + OAuthEnvironmentHandler + +
+
+ + + + +
+
+
+ OAuthAdapter +
+
+
+
+ + OAuthAdapter + +
+
+ + + + +
+
+
+ PassportOAuthAuthenticatorHelper +
+
+
+
+ + PassportOAuthAuthenticatorHelper + +
+
+ + + + + + +
+
+
+ authModule*Provider +
+
+
+
+ + authModule*Provider + +
+
+
+ + + + + Text is not SVG - cannot display + + + +
From 1c522713cd85fd0c2bd226a0d7d92375471f2252 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 13 Jul 2023 21:57:08 +0200 Subject: [PATCH 03/66] auth-backend: throw error if sign-in result does not contain token when preparting identity response Signed-off-by: Patrik Oldsberg --- .../src/providers/prepareBackstageIdentityResponse.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts b/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts index 761618c225..e2753d6648 100644 --- a/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts +++ b/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { InputError } from '@backstage/errors'; import { BackstageIdentityResponse, BackstageSignInResult, @@ -34,6 +35,10 @@ function parseJwtPayload(token: string) { 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 { From 747712f930fc4bb76b5c13f907b028cab42750b1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 13 Jul 2023 21:57:45 +0200 Subject: [PATCH 04/66] auth-backend: add optional token_type field in OAuthResult Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/src/lib/oauth/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/auth-backend/src/lib/oauth/types.ts b/plugins/auth-backend/src/lib/oauth/types.ts index e960af2988..46009218a9 100644 --- a/plugins/auth-backend/src/lib/oauth/types.ts +++ b/plugins/auth-backend/src/lib/oauth/types.ts @@ -45,6 +45,7 @@ export type OAuthResult = { params: { id_token?: string; scope: string; + token_type?: string; expires_in: number; }; accessToken: string; From 318816cef913d0a3cb87e4995ecb1409ecd08ac3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 22 Jul 2023 09:46:47 +0200 Subject: [PATCH 05/66] auth-backend: move a few types to auth-node Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/src/identity/types.ts | 22 +--- plugins/auth-backend/src/providers/types.ts | 90 ++-------------- plugins/auth-node/package.json | 3 + plugins/auth-node/src/index.ts | 4 + plugins/auth-node/src/types.ts | 113 ++++++++++++++++++++ yarn.lock | 3 + 6 files changed, 137 insertions(+), 98 deletions(-) diff --git a/plugins/auth-backend/src/identity/types.ts b/plugins/auth-backend/src/identity/types.ts index e325f74c81..fcfc0345cc 100644 --- a/plugins/auth-backend/src/identity/types.ts +++ b/plugins/auth-backend/src/identity/types.ts @@ -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 { @@ -25,26 +25,10 @@ export interface AnyJWK extends Record { } /** - * 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; -}; +export type TokenParams = _TokenParams; /** * A TokenIssuer is able to issue verifiable ID Tokens on demand. diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index cba4ee1ee4..e382148b5d 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import { GetEntitiesRequest } from '@backstage/catalog-client'; -import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { BackstageIdentityResponse, @@ -23,71 +21,24 @@ import { } from '@backstage/plugin-auth-node'; import express from 'express'; import { LoggerService } from '@backstage/backend-plugin-api'; -import { TokenParams } from '../identity/types'; +import { + AuthResolverCatalogUserQuery as _AuthResolverCatalogUserQuery, + AuthResolverContext as _AuthResolverContext, + ProfileInfo as _ProfileInfo, +} from '@backstage/plugin-auth-node'; 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; - } - | { - filter: Exclude; - }; +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; -}; +export type AuthResolverContext = _AuthResolverContext; /** * The callback used to resolve the cookie configuration for auth providers that use cookies. @@ -219,29 +170,10 @@ export type AuthResponse = { }; /** - * 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 ProfileInfo = _ProfileInfo; /** * Type of sign in information context. Includes the profile information and diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index f57ff4e2fb..a7a70e02d2 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -29,8 +29,11 @@ }, "dependencies": { "@backstage/backend-common": "workspace:^", + "@backstage/catalog-client": "workspace:^", + "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/types": "workspace:^", "@types/express": "*", "express": "^4.17.1", "jose": "^4.6.0", diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index a70f667adf..7a18040abc 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -26,8 +26,12 @@ export { IdentityClient } from './IdentityClient'; export type { IdentityApi } from './IdentityApi'; export type { IdentityClientOptions } from './DefaultIdentityClient'; export type { + AuthResolverCatalogUserQuery, + AuthResolverContext, BackstageIdentityResponse, BackstageSignInResult, BackstageUserIdentity, IdentityApiGetIdentityRequest, + ProfileInfo, + TokenParams, } from './types'; diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts index 9aec98a372..823f06320f 100644 --- a/plugins/auth-node/src/types.ts +++ b/plugins/auth-node/src/types.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +import { EntityFilterQuery } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { JsonValue } from '@backstage/types'; import { Request } from 'express'; /** @@ -76,3 +79,113 @@ export type BackstageUserIdentity = { */ ownershipEntityRefs: string[]; }; + +/** + * 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 + */ +export type AuthResolverCatalogUserQuery = + | { + entityRef: + | string + | { + kind?: string; + namespace?: string; + name: string; + }; + } + | { + annotations: Record; + } + | { + filter: EntityFilterQuery; + }; + +/** + * Parameters used to issue new ID Tokens + * + * @public + */ +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; +}; + +/** + * The context that is used for auth processing. + * + * @public + */ +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; +}; + +/** + * 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 + */ +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; +}; diff --git a/yarn.lock b/yarn.lock index cecd8afe31..e3ce7fd2e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4648,9 +4648,12 @@ __metadata: dependencies: "@backstage/backend-common": "workspace:^" "@backstage/backend-test-utils": "workspace:^" + "@backstage/catalog-client": "workspace:^" + "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/types": "workspace:^" "@types/express": "*" express: ^4.17.1 jose: ^4.6.0 From 68ae81a6a7989d9c946f0fdd969bb16bf99d660f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 22 Jul 2023 09:54:09 +0200 Subject: [PATCH 06/66] auth-node: initial oauth authenticator types Signed-off-by: Patrik Oldsberg --- plugins/auth-node/src/oauth/types.ts | 85 ++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 plugins/auth-node/src/oauth/types.ts diff --git a/plugins/auth-node/src/oauth/types.ts b/plugins/auth-node/src/oauth/types.ts new file mode 100644 index 0000000000..4a53beaae6 --- /dev/null +++ b/plugins/auth-node/src/oauth/types.ts @@ -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 { Config } from '@backstage/config'; +import { Request } from 'express'; +import { AuthResolverContext, ProfileInfo } from '../types'; + +export interface OAuthSession { + accessToken: string; + tokenType: string; + idToken?: string; + scope: string; + expiresInSeconds: number; + refreshToken?: string; +} + +export type OAuthProfileTransform = ( + result: OAuthAuthenticatorResult, + context: AuthResolverContext, +) => Promise<{ profile: ProfileInfo }>; + +export interface OAuthAuthenticatorStartInput { + scope: string; + state: string; + req: Request; +} + +export interface OAuthAuthenticatorAuthenticateInput { + req: Request; +} + +export interface OAuthAuthenticatorRefreshInput { + scope: string; + refreshToken: string; + req: Request; +} + +export interface OAuthAuthenticatorLogoutInput { + accessToken?: string; + refreshToken?: string; + req: Request; +} + +export interface OAuthAuthenticatorResult { + fullProfile: TProfile; + session: OAuthSession; +} + +export interface OAuthAuthenticator { + defaultProfileTransform: OAuthProfileTransform; + 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>; + refresh( + input: OAuthAuthenticatorRefreshInput, + ctx: TContext, + ): Promise>; + logout?(input: OAuthAuthenticatorLogoutInput, ctx: TContext): Promise; +} + +export function createOAuthAuthenticator( + authenticator: OAuthAuthenticator, +): OAuthAuthenticator { + return authenticator; +} From 6c7952ee85a5f7860d086f6e6ac02562acef4a07 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 22 Jul 2023 10:42:39 +0200 Subject: [PATCH 07/66] auth-backend: move CookieConfigurer to auth-node Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/src/providers/types.ts | 19 +++---------------- plugins/auth-node/src/index.ts | 1 + plugins/auth-node/src/types.ts | 20 ++++++++++++++++++++ 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index e382148b5d..89c7e92c35 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -24,6 +24,7 @@ import { LoggerService } from '@backstage/backend-plugin-api'; import { AuthResolverCatalogUserQuery as _AuthResolverCatalogUserQuery, AuthResolverContext as _AuthResolverContext, + CookieConfigurer as _CookieConfigurer, ProfileInfo as _ProfileInfo, } from '@backstage/plugin-auth-node'; import { OAuthStartRequest } from '../lib/oauth/types'; @@ -41,24 +42,10 @@ export type AuthResolverCatalogUserQuery = _AuthResolverCatalogUserQuery; 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 = { diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index 7a18040abc..e5ef84e02c 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -31,6 +31,7 @@ export type { BackstageIdentityResponse, BackstageSignInResult, BackstageUserIdentity, + CookieConfigurer, IdentityApiGetIdentityRequest, ProfileInfo, TokenParams, diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts index 823f06320f..383fe50a74 100644 --- a/plugins/auth-node/src/types.ts +++ b/plugins/auth-node/src/types.ts @@ -189,3 +189,23 @@ export type ProfileInfo = { */ picture?: string; }; + +/** + * The callback used to resolve the cookie configuration for auth providers that use cookies. + * @public + */ +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'; +}; From 93427ba7bc289d6c515bedc0f0ba03b6a304c350 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 22 Jul 2023 10:44:55 +0200 Subject: [PATCH 08/66] auth-node: add OAuthCookieManager Signed-off-by: Patrik Oldsberg --- .../auth-node/src/oauth/OAuthCookieManager.ts | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 plugins/auth-node/src/oauth/OAuthCookieManager.ts diff --git a/plugins/auth-node/src/oauth/OAuthCookieManager.ts b/plugins/auth-node/src/oauth/OAuthCookieManager.ts new file mode 100644 index 0000000000..5fe2304610 --- /dev/null +++ b/plugins/auth-node/src/oauth/OAuthCookieManager.ts @@ -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['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]; + } +} From b62b47a6dda7ec557ed5dd143a194824c4ef3d7f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 25 Jul 2023 17:07:46 +0200 Subject: [PATCH 09/66] auth-backend: move a couple more types to auth-node Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/src/providers/types.ts | 146 ++++---------------- plugins/auth-node/package.json | 1 + plugins/auth-node/src/index.ts | 6 + plugins/auth-node/src/types.ts | 131 +++++++++++++++++- yarn.lock | 1 + 5 files changed, 165 insertions(+), 120 deletions(-) diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 89c7e92c35..45d5be7c66 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -14,18 +14,17 @@ * limitations under the License. */ -import { Config } from '@backstage/config'; -import { - BackstageIdentityResponse, - BackstageSignInResult, -} from '@backstage/plugin-auth-node'; -import express from 'express'; -import { LoggerService } from '@backstage/backend-plugin-api'; import { + 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 { OAuthStartRequest } from '../lib/oauth/types'; @@ -47,30 +46,6 @@ export type AuthResolverContext = _AuthResolverContext; */ 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 */ export type OAuthStartResponse = { /** @@ -84,77 +59,28 @@ 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; +export type AuthProviderConfig = _AuthProviderConfig; - /** - * 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; +/** + * @public + * @deprecated import from `@backstage/plugin-auth-node` instead + */ +export type AuthProviderRouteHandlers = _AuthProviderRouteHandlers; - /** - * (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; +/** + * @public + * @deprecated import from `@backstage/plugin-auth-node` instead + */ +export type AuthProviderFactory = _AuthProviderFactory; - /** - * (Optional) Handles sign out requests - * - * Response - * - removes the refresh token cookie - */ - logout?(req: express.Request, res: express.Response): Promise; -} - -/** @public */ -export type AuthProviderFactory = (options: { - providerId: string; - globalConfig: AuthProviderConfig; - config: Config; - logger: LoggerService; - resolverContext: AuthResolverContext; -}) => AuthProviderRouteHandlers; - -/** @public */ -export type AuthResponse = { - providerInfo: ProviderInfo; - profile: ProfileInfo; - backstageIdentity?: BackstageIdentityResponse; -}; +/** + * @public + * @deprecated import `ClientAuthResponse` from `@backstage/plugin-auth-node` instead + */ +export type AuthResponse = _ClientAuthResponse; /** * @public @@ -163,34 +89,16 @@ export type AuthResponse = { export type ProfileInfo = _ProfileInfo; /** - * 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 = { - /** - * 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 SignInInfo = _SignInInfo; /** - * Describes the function which handles the result of a successful - * authentication. Must return a valid {@link @backstage/plugin-auth-node#BackstageSignInResult}. - * * @public + * @deprecated import from `@backstage/plugin-auth-node` instead */ -export type SignInResolver = ( - info: SignInInfo, - context: AuthResolverContext, -) => Promise; +export type SignInResolver = _SignInResolver; /** * The return type of an authentication handler. Must contain valid profile diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index a7a70e02d2..a570f24bff 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index e5ef84e02c..2e25eec10e 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -26,13 +26,19 @@ export { IdentityClient } from './IdentityClient'; export type { IdentityApi } from './IdentityApi'; export type { IdentityClientOptions } from './DefaultIdentityClient'; export type { + AuthProviderConfig, + AuthProviderRouteHandlers, + AuthProviderFactory, AuthResolverCatalogUserQuery, AuthResolverContext, BackstageIdentityResponse, BackstageSignInResult, BackstageUserIdentity, + ClientAuthResponse, CookieConfigurer, IdentityApiGetIdentityRequest, ProfileInfo, + SignInInfo, + SignInResolver, TokenParams, } from './types'; diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts index 383fe50a74..d6d24d17b4 100644 --- a/plugins/auth-node/src/types.ts +++ b/plugins/auth-node/src/types.ts @@ -14,10 +14,12 @@ * limitations under the License. */ +import { LoggerService } from '@backstage/backend-plugin-api'; import { EntityFilterQuery } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; import { JsonValue } from '@backstage/types'; -import { Request } from 'express'; +import { Request, Response } from 'express'; /** * A representation of a successful Backstage sign-in. @@ -165,6 +167,133 @@ export type AuthResolverContext = { ): Promise; }; +/** + * 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 + */ +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: Request, res: Response): Promise; + + /** + * 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: Request, res: Response): Promise; + + /** + * (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: Request, res: Response): Promise; + + /** + * (Optional) Handles sign out requests + * + * Response + * - removes the refresh token cookie + */ + logout?(req: Request, res: Response): Promise; +} + +/** @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 */ +export type AuthProviderFactory = (options: { + providerId: string; + globalConfig: AuthProviderConfig; + config: Config; + logger: LoggerService; + resolverContext: AuthResolverContext; +}) => AuthProviderRouteHandlers; + +/** @public */ +export type ClientAuthResponse = { + providerInfo: ProviderInfo; + profile: ProfileInfo; + backstageIdentity?: BackstageIdentityResponse; +}; + +/** + * Type of sign in information context. Includes the profile information and + * authentication result which contains auth related information. + * + * @public + */ +export type SignInInfo = { + /** + * The simple profile passed down for use in the frontend. + */ + profile: ProfileInfo; + + /** + * The authentication result that was received from the authentication + * provider. + */ + result: TAuthResult; +}; + +/** + * Describes the function which handles the result of a successful + * authentication. Must return a valid {@link @backstage/plugin-auth-node#BackstageSignInResult}. + * + * @public + */ +export type SignInResolver = ( + info: SignInInfo, + context: AuthResolverContext, +) => Promise; + /** * Used to display login information to user, i.e. sidebar popup. * diff --git a/yarn.lock b/yarn.lock index e3ce7fd2e3..997bfb5aa6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4647,6 +4647,7 @@ __metadata: resolution: "@backstage/plugin-auth-node@workspace:plugins/auth-node" dependencies: "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-test-utils": "workspace:^" "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" From 48793dfc95184eb10422dd11a668ab1ce63e3f9e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 26 Jul 2023 10:32:50 +0200 Subject: [PATCH 10/66] auth-backend: move prepareBackstageIdentityResponse to auth-node Signed-off-by: Patrik Oldsberg --- .../prepareBackstageIdentityResponse.ts | 36 ++----------- plugins/auth-node/src/identity/index.ts | 17 ++++++ .../prepareBackstageIdentityResponse.test.ts | 0 .../prepareBackstageIdentityResponse.ts | 52 +++++++++++++++++++ plugins/auth-node/src/index.ts | 1 + 5 files changed, 74 insertions(+), 32 deletions(-) create mode 100644 plugins/auth-node/src/identity/index.ts rename plugins/{auth-backend/src/providers => auth-node/src/identity}/prepareBackstageIdentityResponse.test.ts (100%) create mode 100644 plugins/auth-node/src/identity/prepareBackstageIdentityResponse.ts diff --git a/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts b/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts index e2753d6648..1fa8f4a2fa 100644 --- a/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts +++ b/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.ts @@ -14,39 +14,11 @@ * 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()); -} +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 { - 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 ?? [], - }, - }; -} +export const prepareBackstageIdentityResponse = + _prepareBackstageIdentityResponse; diff --git a/plugins/auth-node/src/identity/index.ts b/plugins/auth-node/src/identity/index.ts new file mode 100644 index 0000000000..4e9b129605 --- /dev/null +++ b/plugins/auth-node/src/identity/index.ts @@ -0,0 +1,17 @@ +/* + * 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'; diff --git a/plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.test.ts b/plugins/auth-node/src/identity/prepareBackstageIdentityResponse.test.ts similarity index 100% rename from plugins/auth-backend/src/providers/prepareBackstageIdentityResponse.test.ts rename to plugins/auth-node/src/identity/prepareBackstageIdentityResponse.test.ts diff --git a/plugins/auth-node/src/identity/prepareBackstageIdentityResponse.ts b/plugins/auth-node/src/identity/prepareBackstageIdentityResponse.ts new file mode 100644 index 0000000000..e2753d6648 --- /dev/null +++ b/plugins/auth-node/src/identity/prepareBackstageIdentityResponse.ts @@ -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 ?? [], + }, + }; +} diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index 2e25eec10e..c3ab152149 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -20,6 +20,7 @@ * @packageDocumentation */ +export * from './identity'; export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader'; export { DefaultIdentityClient } from './DefaultIdentityClient'; export { IdentityClient } from './IdentityClient'; From 4f7eed7b257129195662dc4567d6b9608778bf9b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 26 Jul 2023 11:16:35 +0200 Subject: [PATCH 11/66] auth-node: added duplicate and refactored oauth state codec Signed-off-by: Patrik Oldsberg --- plugins/auth-node/package.json | 1 + plugins/auth-node/src/oauth/state.ts | 67 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 plugins/auth-node/src/oauth/state.ts diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index a570f24bff..800ea22b61 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -38,6 +38,7 @@ "@types/express": "*", "express": "^4.17.1", "jose": "^4.6.0", + "lodash": "^4.17.21", "node-fetch": "^2.6.7", "winston": "^3.2.1" }, diff --git a/plugins/auth-node/src/oauth/state.ts b/plugins/auth-node/src/oauth/state.ts new file mode 100644 index 0000000000..668fe3736a --- /dev/null +++ b/plugins/auth-node/src/oauth/state.ts @@ -0,0 +1,67 @@ +/* + * 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'; + +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 */ +export type OAuthStateEncoder = ( + state: OAuthState, + context: { req: Request }, +) => Promise<{ encodedState: string }>; + +/** @public */ +export type OAuthStateDecoder = ( + encodedState: string, + context: { req: Request }, +) => Promise<{ state: OAuthState }>; + +/** @public */ +export const defaultStateEncoder: OAuthStateEncoder = async state => { + const stateString = new URLSearchParams( + pickBy(state, value => value !== undefined), + ).toString(); + + return { encodedState: Buffer.from(stateString, 'utf-8').toString('hex') }; +}; + +/** @public */ +export const defaultStateDecoder: OAuthStateDecoder = async encodedState => { + const state = Object.fromEntries( + new URLSearchParams(Buffer.from(encodedState, 'hex').toString('utf-8')), + ); + if ( + !state.nonce || + !state.env || + state.nonce?.length === 0 || + state.env?.length === 0 + ) { + throw Error(`Invalid state passed via request`); + } + + return { state: state as OAuthState }; +}; From 14fd4fb7c83e4cd86a2a79c52e43ca44397c59c3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 26 Jul 2023 13:29:50 +0200 Subject: [PATCH 12/66] auth-node: add createOAuthHandlers Signed-off-by: Patrik Oldsberg --- .../src/oauth/createOAuthHandlers.ts | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 plugins/auth-node/src/oauth/createOAuthHandlers.ts diff --git a/plugins/auth-node/src/oauth/createOAuthHandlers.ts b/plugins/auth-node/src/oauth/createOAuthHandlers.ts new file mode 100644 index 0000000000..548ce05701 --- /dev/null +++ b/plugins/auth-node/src/oauth/createOAuthHandlers.ts @@ -0,0 +1,340 @@ +/* + * 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 { defaultStateDecoder, defaultStateEncoder, OAuthState } from './state'; +import { postMessageResponse, ensuresXRequestedWith } from '../flow'; +import { prepareBackstageIdentityResponse } from '../identity'; +import { OAuthCookieManager } from './OAuthCookieManager'; +import { + AuthProviderRouteHandlers, + AuthResolverContext, + ClientAuthResponse, + CookieConfigurer, + SignInResolver, +} from '../types'; +import { + OAuthAuthenticator, + OAuthAuthenticatorResult, + OAuthProfileTransform, +} from './types'; +import { Config } from '@backstage/config'; + +/** @public */ +export interface OAuthHandlersOptions { + authenticator: OAuthAuthenticator; + appUrl: string; + baseUrl: string; + isOriginAllowed: (origin: string) => boolean; + callbackUrl: string; + providerId: string; + config: Config; + resolverContext: AuthResolverContext; + profileTransform: OAuthProfileTransform; + cookieConfigurer?: CookieConfigurer; + signInResolver?: SignInResolver>; +} + +/** @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 createOAuthHandlers( + options: OAuthHandlersOptions, +): AuthProviderRouteHandlers { + const { + authenticator, + config, + baseUrl, + appUrl, + providerId, + isOriginAllowed, + cookieConfigurer, + resolverContext, + profileTransform, + signInResolver, + } = options; + + const defaultAppOrigin = new URL(appUrl).origin; + const callbackUrl = + config.getOptionalString('callbackUrl') ?? + `${baseUrl}/${providerId}/handler/frame`; + + 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 { + // 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 { encodedState } = await defaultStateEncoder(state, { req }); + + 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 { + let appOrigin = defaultAppOrigin; + + try { + const { state } = await defaultStateDecoder( + req.query.state?.toString() ?? '', + { req }, + ); + + 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 Error('Auth response is missing cookie nonce'); + } + if (stateNonce.length === 0) { + throw new Error('Auth response is missing state nonce'); + } + if (cookieNonce !== stateNonce) { + throw new Error('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); + } + // post message back to popup if successful + return postMessageResponse(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 + return postMessageResponse(res, appOrigin, { + type: 'authorization_response', + error: { name, message }, + }); + } + }, + + async logout( + this: never, + req: express.Request, + res: express.Response, + ): Promise { + if (!ensuresXRequestedWith(req)) { + 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 { + if (!ensuresXRequestedWith(req)) { + 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); + } + }, + }; +} From feefbd3da6377e1f72cf71d14cf4a7e3a6aa9c80 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 26 Jul 2023 14:19:16 +0200 Subject: [PATCH 13/66] auth-node: add PassportHelpers Signed-off-by: Patrik Oldsberg --- plugins/auth-node/package.json | 1 + plugins/auth-node/src/index.ts | 1 + .../auth-node/src/passport/PassportHelpers.ts | 246 ++++++++++++++++++ plugins/auth-node/src/passport/index.ts | 18 ++ plugins/auth-node/src/passport/types.ts | 29 +++ yarn.lock | 1 + 6 files changed, 296 insertions(+) create mode 100644 plugins/auth-node/src/passport/PassportHelpers.ts create mode 100644 plugins/auth-node/src/passport/index.ts create mode 100644 plugins/auth-node/src/passport/types.ts diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index 800ea22b61..92ad426a11 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -40,6 +40,7 @@ "jose": "^4.6.0", "lodash": "^4.17.21", "node-fetch": "^2.6.7", + "passport": "^0.6.0", "winston": "^3.2.1" }, "devDependencies": { diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index c3ab152149..a1f4ef3206 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -21,6 +21,7 @@ */ export * from './identity'; +export * from './passport'; export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader'; export { DefaultIdentityClient } from './DefaultIdentityClient'; export { IdentityClient } from './IdentityClient'; diff --git a/plugins/auth-node/src/passport/PassportHelpers.ts b/plugins/auth-node/src/passport/PassportHelpers.ts new file mode 100644 index 0000000000..d24163bf34 --- /dev/null +++ b/plugins/auth-node/src/passport/PassportHelpers.ts @@ -0,0 +1,246 @@ +/* + * 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 { + const payloadStr = token.split('.')[1]; + if (!payloadStr) { + throw new Error('Invalid JWT token'); + } + + let payload: unknown; + try { + payload = JSON.parse( + atob(payloadStr.replace(/-/g, '+').replace(/_/g, '/')), + ); + } 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; +} + +/** @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 = 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, + ): 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( + req: Request, + providerStrategy: Strategy, + options?: Record, + ): 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 { + 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); + } + }, + ); + }); + } +} diff --git a/plugins/auth-node/src/passport/index.ts b/plugins/auth-node/src/passport/index.ts new file mode 100644 index 0000000000..f70c0af921 --- /dev/null +++ b/plugins/auth-node/src/passport/index.ts @@ -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'; diff --git a/plugins/auth-node/src/passport/types.ts b/plugins/auth-node/src/passport/types.ts new file mode 100644 index 0000000000..4a3f41b6b2 --- /dev/null +++ b/plugins/auth-node/src/passport/types.ts @@ -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 = ( + err?: Error, + result?: TResult, + privateInfo?: TPrivateInfo, +) => void; diff --git a/yarn.lock b/yarn.lock index 997bfb5aa6..7c10183f74 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4661,6 +4661,7 @@ __metadata: lodash: ^4.17.21 msw: ^1.0.0 node-fetch: ^2.6.7 + passport: ^0.6.0 uuid: ^8.0.0 winston: ^3.2.1 languageName: unknown From 679239161dc936a1f1ef0cd2fde4ea2441aab244 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 26 Jul 2023 14:24:14 +0200 Subject: [PATCH 14/66] auth-node: add PassportOAuthAuthenticatorHelper Signed-off-by: Patrik Oldsberg --- .../oauth/PassportOAuthAuthenticatorHelper.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts diff --git a/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts b/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts new file mode 100644 index 0000000000..dc76207342 --- /dev/null +++ b/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts @@ -0,0 +1,112 @@ +/* + * 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 { PassportHelpers, PassportProfile } from '../passport'; +import { + OAuthAuthenticatorAuthenticateInput, + OAuthAuthenticatorRefreshInput, + OAuthAuthenticatorResult, + OAuthAuthenticatorStartInput, +} from './types'; + +/** @internal */ +export type PassportOAuthResult = { + fullProfile: PassportProfile; + params: { + id_token?: string; + scope: string; + token_type?: string; + expires_in: number; + }; + accessToken: string; +}; + +/** @public */ +export class PassportOAuthAuthenticatorHelper { + static from(strategy: Strategy) { + return new PassportOAuthAuthenticatorHelper(strategy); + } + + readonly #strategy: Strategy; + + private constructor(strategy: Strategy) { + this.#strategy = strategy; + } + + async start( + input: OAuthAuthenticatorStartInput, + options: Record, + ): Promise<{ url: string; status?: number }> { + return PassportHelpers.executeRedirectStrategy(input.req, this.#strategy, { + scope: input.scope, + state: input.state, + ...options, + }); + } + + async authenticate( + input: OAuthAuthenticatorAuthenticateInput, + ): Promise> { + const { result, privateInfo } = + await PassportHelpers.executeFrameHandlerStrategy< + PassportOAuthResult, + { refreshToken?: string } + >(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> { + 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 { + const profile = await PassportHelpers.executeFetchUserProfileStrategy( + this.#strategy, + accessToken, + ); + return profile; + } +} From ac8f47aa694759de5fda6ad7d878dd50ff8cce7d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 26 Jul 2023 14:30:03 +0200 Subject: [PATCH 15/66] auth-node: inline ensuresXRequestedWith Signed-off-by: Patrik Oldsberg --- plugins/auth-node/src/oauth/createOAuthHandlers.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/auth-node/src/oauth/createOAuthHandlers.ts b/plugins/auth-node/src/oauth/createOAuthHandlers.ts index 548ce05701..151d4d418e 100644 --- a/plugins/auth-node/src/oauth/createOAuthHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthHandlers.ts @@ -24,7 +24,7 @@ import { NotAllowedError, } from '@backstage/errors'; import { defaultStateDecoder, defaultStateEncoder, OAuthState } from './state'; -import { postMessageResponse, ensuresXRequestedWith } from '../flow'; +import { postMessageResponse } from '../flow'; import { prepareBackstageIdentityResponse } from '../identity'; import { OAuthCookieManager } from './OAuthCookieManager'; import { @@ -259,7 +259,8 @@ export function createOAuthHandlers( req: express.Request, res: express.Response, ): Promise { - if (!ensuresXRequestedWith(req)) { + // We use this as a lightweight CSRF protection + if (req.header('X-Requested-With') !== 'XMLHttpRequest') { throw new AuthenticationError('Invalid X-Requested-With header'); } @@ -279,7 +280,8 @@ export function createOAuthHandlers( req: express.Request, res: express.Response, ): Promise { - if (!ensuresXRequestedWith(req)) { + // We use this as a lightweight CSRF protection + if (req.header('X-Requested-With') !== 'XMLHttpRequest') { throw new AuthenticationError('Invalid X-Requested-With header'); } From 52af2a8472fd9ef8aa0dff49ec0562c572b18b50 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 26 Jul 2023 16:19:59 +0200 Subject: [PATCH 16/66] auth-node: add sendWebMessageResponse Signed-off-by: Patrik Oldsberg --- plugins/auth-node/src/flow/index.ts | 20 ++ .../src/flow/sendWebMessageResponse.test.ts | 179 ++++++++++++++++++ .../src/flow/sendWebMessageResponse.ts | 87 +++++++++ plugins/auth-node/src/index.ts | 1 + .../src/oauth/createOAuthHandlers.ts | 6 +- 5 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 plugins/auth-node/src/flow/index.ts create mode 100644 plugins/auth-node/src/flow/sendWebMessageResponse.test.ts create mode 100644 plugins/auth-node/src/flow/sendWebMessageResponse.ts diff --git a/plugins/auth-node/src/flow/index.ts b/plugins/auth-node/src/flow/index.ts new file mode 100644 index 0000000000..51d91e5d58 --- /dev/null +++ b/plugins/auth-node/src/flow/index.ts @@ -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'; diff --git a/plugins/auth-node/src/flow/sendWebMessageResponse.test.ts b/plugins/auth-node/src/flow/sendWebMessageResponse.test.ts new file mode 100644 index 0000000000..fd706159ba --- /dev/null +++ b/plugins/auth-node/src/flow/sendWebMessageResponse.test.ts @@ -0,0 +1,179 @@ +/* + * 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'; + +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(data)); + + 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(responseBody.match(/.postMessage\(/g)).toHaveLength(2); + expect( + responseBody.match(/.postMessage\([a-zA-z.()]*, \'\*\'\)/g), + ).toHaveLength(1); + + const errData: WebMessageResponse = { + type: 'authorization_response', + error: new Error('Unknown error occurred'), + }; + sendWebMessageResponse(mockResponse, appOrigin, errData); + expect(responseBody.match(/.postMessage\(/g)).toHaveLength(2); + expect( + responseBody.match(/.postMessage\([a-zA-z.()]*, \'\*\'\)/g), + ).toHaveLength(1); + }); + + 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'), + ); + }); + }); +}); diff --git a/plugins/auth-node/src/flow/sendWebMessageResponse.ts b/plugins/auth-node/src/flow/sendWebMessageResponse.ts new file mode 100644 index 0000000000..7bb658bd11 --- /dev/null +++ b/plugins/auth-node/src/flow/sendWebMessageResponse.ts @@ -0,0 +1,87 @@ +/* + * 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'; + +/** + * 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; + } + | { + 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); + 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(``); +} diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index a1f4ef3206..7740651d44 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -20,6 +20,7 @@ * @packageDocumentation */ +export * from './flow'; export * from './identity'; export * from './passport'; export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader'; diff --git a/plugins/auth-node/src/oauth/createOAuthHandlers.ts b/plugins/auth-node/src/oauth/createOAuthHandlers.ts index 151d4d418e..9f7bc7f6f9 100644 --- a/plugins/auth-node/src/oauth/createOAuthHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthHandlers.ts @@ -24,7 +24,7 @@ import { NotAllowedError, } from '@backstage/errors'; import { defaultStateDecoder, defaultStateEncoder, OAuthState } from './state'; -import { postMessageResponse } from '../flow'; +import { sendWebMessageResponse } from '../flow'; import { prepareBackstageIdentityResponse } from '../identity'; import { OAuthCookieManager } from './OAuthCookieManager'; import { @@ -238,7 +238,7 @@ export function createOAuthHandlers( res.redirect(state.redirectUrl); } // post message back to popup if successful - return postMessageResponse(res, appOrigin, { + return sendWebMessageResponse(res, appOrigin, { type: 'authorization_response', response, }); @@ -247,7 +247,7 @@ export function createOAuthHandlers( ? error : new Error('Encountered invalid error'); // Being a bit safe and not forwarding the bad value // post error message back to popup if failure - return postMessageResponse(res, appOrigin, { + return sendWebMessageResponse(res, appOrigin, { type: 'authorization_response', error: { name, message }, }); From 1e5baf0c6e0aab0b4e98924b354cdf82d3809950 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 26 Jul 2023 16:42:40 +0200 Subject: [PATCH 17/66] auth-node: move identity related modules to identity dir Signed-off-by: Patrik Oldsberg --- .../{ => identity}/DefaultIdentityClient.test.ts | 2 +- .../src/{ => identity}/DefaultIdentityClient.ts | 7 ++----- .../auth-node/src/{ => identity}/IdentityApi.ts | 16 ++++++++++++---- .../src/{ => identity}/IdentityClient.test.ts | 0 .../src/{ => identity}/IdentityClient.ts | 2 +- ...getBearerTokenFromAuthorizationHeader.test.ts | 0 .../getBearerTokenFromAuthorizationHeader.ts | 0 plugins/auth-node/src/identity/index.ts | 7 +++++++ plugins/auth-node/src/index.ts | 6 ------ plugins/auth-node/src/types.ts | 9 --------- 10 files changed, 23 insertions(+), 26 deletions(-) rename plugins/auth-node/src/{ => identity}/DefaultIdentityClient.test.ts (99%) rename plugins/auth-node/src/{ => identity}/DefaultIdentityClient.ts (97%) rename plugins/auth-node/src/{ => identity}/IdentityApi.ts (80%) rename plugins/auth-node/src/{ => identity}/IdentityClient.test.ts (100%) rename plugins/auth-node/src/{ => identity}/IdentityClient.ts (97%) rename plugins/auth-node/src/{ => identity}/getBearerTokenFromAuthorizationHeader.test.ts (100%) rename plugins/auth-node/src/{ => identity}/getBearerTokenFromAuthorizationHeader.ts (100%) diff --git a/plugins/auth-node/src/DefaultIdentityClient.test.ts b/plugins/auth-node/src/identity/DefaultIdentityClient.test.ts similarity index 99% rename from plugins/auth-node/src/DefaultIdentityClient.test.ts rename to plugins/auth-node/src/identity/DefaultIdentityClient.test.ts index 13d9c5a17b..b9bf3cabd2 100644 --- a/plugins/auth-node/src/DefaultIdentityClient.test.ts +++ b/plugins/auth-node/src/identity/DefaultIdentityClient.test.ts @@ -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 { use: 'sig'; diff --git a/plugins/auth-node/src/DefaultIdentityClient.ts b/plugins/auth-node/src/identity/DefaultIdentityClient.ts similarity index 97% rename from plugins/auth-node/src/DefaultIdentityClient.ts rename to plugins/auth-node/src/identity/DefaultIdentityClient.ts index 8af8bf09e1..1095609dec 100644 --- a/plugins/auth-node/src/DefaultIdentityClient.ts +++ b/plugins/auth-node/src/identity/DefaultIdentityClient.ts @@ -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; diff --git a/plugins/auth-node/src/IdentityApi.ts b/plugins/auth-node/src/identity/IdentityApi.ts similarity index 80% rename from plugins/auth-node/src/IdentityApi.ts rename to plugins/auth-node/src/identity/IdentityApi.ts index 86171fa3ab..8322b9f827 100644 --- a/plugins/auth-node/src/IdentityApi.ts +++ b/plugins/auth-node/src/identity/IdentityApi.ts @@ -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; +}; /** * An identity client api to authenticate Backstage diff --git a/plugins/auth-node/src/IdentityClient.test.ts b/plugins/auth-node/src/identity/IdentityClient.test.ts similarity index 100% rename from plugins/auth-node/src/IdentityClient.test.ts rename to plugins/auth-node/src/identity/IdentityClient.test.ts diff --git a/plugins/auth-node/src/IdentityClient.ts b/plugins/auth-node/src/identity/IdentityClient.ts similarity index 97% rename from plugins/auth-node/src/IdentityClient.ts rename to plugins/auth-node/src/identity/IdentityClient.ts index 8252a61f81..fd6580dbb2 100644 --- a/plugins/auth-node/src/IdentityClient.ts +++ b/plugins/auth-node/src/identity/IdentityClient.ts @@ -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 diff --git a/plugins/auth-node/src/getBearerTokenFromAuthorizationHeader.test.ts b/plugins/auth-node/src/identity/getBearerTokenFromAuthorizationHeader.test.ts similarity index 100% rename from plugins/auth-node/src/getBearerTokenFromAuthorizationHeader.test.ts rename to plugins/auth-node/src/identity/getBearerTokenFromAuthorizationHeader.test.ts diff --git a/plugins/auth-node/src/getBearerTokenFromAuthorizationHeader.ts b/plugins/auth-node/src/identity/getBearerTokenFromAuthorizationHeader.ts similarity index 100% rename from plugins/auth-node/src/getBearerTokenFromAuthorizationHeader.ts rename to plugins/auth-node/src/identity/getBearerTokenFromAuthorizationHeader.ts diff --git a/plugins/auth-node/src/identity/index.ts b/plugins/auth-node/src/identity/index.ts index 4e9b129605..1a39c7aba3 100644 --- a/plugins/auth-node/src/identity/index.ts +++ b/plugins/auth-node/src/identity/index.ts @@ -15,3 +15,10 @@ */ export { prepareBackstageIdentityResponse } from './prepareBackstageIdentityResponse'; +export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader'; +export { + DefaultIdentityClient, + type IdentityClientOptions, +} from './DefaultIdentityClient'; +export { IdentityClient } from './IdentityClient'; +export type { IdentityApi } from './IdentityApi'; diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index 7740651d44..6c0993203b 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -23,11 +23,6 @@ export * from './flow'; export * from './identity'; export * from './passport'; -export { getBearerTokenFromAuthorizationHeader } from './getBearerTokenFromAuthorizationHeader'; -export { DefaultIdentityClient } from './DefaultIdentityClient'; -export { IdentityClient } from './IdentityClient'; -export type { IdentityApi } from './IdentityApi'; -export type { IdentityClientOptions } from './DefaultIdentityClient'; export type { AuthProviderConfig, AuthProviderRouteHandlers, @@ -39,7 +34,6 @@ export type { BackstageUserIdentity, ClientAuthResponse, CookieConfigurer, - IdentityApiGetIdentityRequest, ProfileInfo, SignInInfo, SignInResolver, diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts index d6d24d17b4..409ad71ff9 100644 --- a/plugins/auth-node/src/types.ts +++ b/plugins/auth-node/src/types.ts @@ -36,15 +36,6 @@ export interface BackstageSignInResult { token: string; } -/** - * Options to request the identity from a Backstage backend request - * - * @public - */ -export type IdentityApiGetIdentityRequest = { - request: Request; -}; - /** * Response object containing the {@link BackstageUserIdentity} and the token * from the authentication provider. From 6f5414273c9b6e87881766f8683684484016ffeb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 26 Jul 2023 17:56:44 +0200 Subject: [PATCH 18/66] auth-node: add default OAuth profile transform Signed-off-by: Patrik Oldsberg --- .../src/oauth/PassportOAuthAuthenticatorHelper.ts | 9 +++++++++ plugins/auth-node/src/oauth/createOAuthHandlers.ts | 5 +++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts b/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts index dc76207342..7c188435d5 100644 --- a/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts +++ b/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts @@ -21,6 +21,7 @@ import { OAuthAuthenticatorRefreshInput, OAuthAuthenticatorResult, OAuthAuthenticatorStartInput, + OAuthProfileTransform, } from './types'; /** @internal */ @@ -37,6 +38,14 @@ export type PassportOAuthResult = { /** @public */ export class PassportOAuthAuthenticatorHelper { + static defaultProfileTransform: OAuthProfileTransform = + async input => ({ + profile: PassportHelpers.transformProfile( + input.fullProfile, + input.session.idToken, + ), + }); + static from(strategy: Strategy) { return new PassportOAuthAuthenticatorHelper(strategy); } diff --git a/plugins/auth-node/src/oauth/createOAuthHandlers.ts b/plugins/auth-node/src/oauth/createOAuthHandlers.ts index 9f7bc7f6f9..e4fd3e5a50 100644 --- a/plugins/auth-node/src/oauth/createOAuthHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthHandlers.ts @@ -51,7 +51,7 @@ export interface OAuthHandlersOptions { providerId: string; config: Config; resolverContext: AuthResolverContext; - profileTransform: OAuthProfileTransform; + profileTransform?: OAuthProfileTransform; cookieConfigurer?: CookieConfigurer; signInResolver?: SignInResolver>; } @@ -89,7 +89,6 @@ export function createOAuthHandlers( isOriginAllowed, cookieConfigurer, resolverContext, - profileTransform, signInResolver, } = options; @@ -98,6 +97,8 @@ export function createOAuthHandlers( config.getOptionalString('callbackUrl') ?? `${baseUrl}/${providerId}/handler/frame`; + const profileTransform = + options.profileTransform ?? authenticator.defaultProfileTransform; const authenticatorCtx = authenticator.initialize({ config, callbackUrl }); const cookieManager = new OAuthCookieManager({ baseUrl, From a49f1dc7e81e0e673198187a7508578cb7771f52 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 26 Jul 2023 17:58:52 +0200 Subject: [PATCH 19/66] auth-node: add oauth index exports Signed-off-by: Patrik Oldsberg --- plugins/auth-node/src/index.ts | 1 + plugins/auth-node/src/oauth/index.ts | 32 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 plugins/auth-node/src/oauth/index.ts diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index 6c0993203b..b055e3a7d3 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -22,6 +22,7 @@ export * from './flow'; export * from './identity'; +export * from './oauth'; export * from './passport'; export type { AuthProviderConfig, diff --git a/plugins/auth-node/src/oauth/index.ts b/plugins/auth-node/src/oauth/index.ts new file mode 100644 index 0000000000..52c7b859f7 --- /dev/null +++ b/plugins/auth-node/src/oauth/index.ts @@ -0,0 +1,32 @@ +/* + * 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 { + createOAuthHandlers, + type OAuthHandlersOptions, +} from './createOAuthHandlers'; +export { PassportOAuthAuthenticatorHelper } from './PassportOAuthAuthenticatorHelper'; +export { + createOAuthAuthenticator, + type OAuthAuthenticator, + type OAuthAuthenticatorAuthenticateInput, + type OAuthAuthenticatorLogoutInput, + type OAuthAuthenticatorRefreshInput, + type OAuthAuthenticatorResult, + type OAuthAuthenticatorStartInput, + type OAuthProfileTransform, + type OAuthSession, +} from './types'; From c723a90f3220085e93f7cf68e8230e7616d21980 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 26 Jul 2023 18:00:42 +0200 Subject: [PATCH 20/66] auth-node: add providers extension point Signed-off-by: Patrik Oldsberg --- .../extensions/AuthProvidersExtensionPoint.ts | 35 +++++++++++++++++++ plugins/auth-node/src/extensions/index.ts | 21 +++++++++++ plugins/auth-node/src/index.ts | 1 + 3 files changed, 57 insertions(+) create mode 100644 plugins/auth-node/src/extensions/AuthProvidersExtensionPoint.ts create mode 100644 plugins/auth-node/src/extensions/index.ts diff --git a/plugins/auth-node/src/extensions/AuthProvidersExtensionPoint.ts b/plugins/auth-node/src/extensions/AuthProvidersExtensionPoint.ts new file mode 100644 index 0000000000..b67d5be017 --- /dev/null +++ b/plugins/auth-node/src/extensions/AuthProvidersExtensionPoint.ts @@ -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({ + id: 'auth.providers', + }); diff --git a/plugins/auth-node/src/extensions/index.ts b/plugins/auth-node/src/extensions/index.ts new file mode 100644 index 0000000000..43e1751685 --- /dev/null +++ b/plugins/auth-node/src/extensions/index.ts @@ -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'; diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index b055e3a7d3..e857f8af97 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -20,6 +20,7 @@ * @packageDocumentation */ +export * from './extensions'; export * from './flow'; export * from './identity'; export * from './oauth'; From 5195c2adaa0aa67674efe79052e045652c44d035 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 29 Jul 2023 11:17:11 +0200 Subject: [PATCH 21/66] auth-node: provide passport OAuth helper types Signed-off-by: Patrik Oldsberg --- .../oauth/PassportOAuthAuthenticatorHelper.ts | 21 ++++++++++++++++--- plugins/auth-node/src/oauth/index.ts | 7 ++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts b/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts index 7c188435d5..c7fe81c3ee 100644 --- a/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts +++ b/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts @@ -15,7 +15,11 @@ */ import { Strategy } from 'passport'; -import { PassportHelpers, PassportProfile } from '../passport'; +import { + PassportDoneCallback, + PassportHelpers, + PassportProfile, +} from '../passport'; import { OAuthAuthenticatorAuthenticateInput, OAuthAuthenticatorRefreshInput, @@ -24,7 +28,7 @@ import { OAuthProfileTransform, } from './types'; -/** @internal */ +/** @public */ export type PassportOAuthResult = { fullProfile: PassportProfile; params: { @@ -36,6 +40,17 @@ export type PassportOAuthResult = { accessToken: string; }; +/** @public */ +export type PassportOAuthPrivateInfo = { + refreshToken?: string; +}; + +/** @public */ +export type PassportOAuthDoneCallback = PassportDoneCallback< + PassportOAuthResult, + PassportOAuthPrivateInfo +>; + /** @public */ export class PassportOAuthAuthenticatorHelper { static defaultProfileTransform: OAuthProfileTransform = @@ -73,7 +88,7 @@ export class PassportOAuthAuthenticatorHelper { const { result, privateInfo } = await PassportHelpers.executeFrameHandlerStrategy< PassportOAuthResult, - { refreshToken?: string } + PassportOAuthPrivateInfo >(input.req, this.#strategy); return { diff --git a/plugins/auth-node/src/oauth/index.ts b/plugins/auth-node/src/oauth/index.ts index 52c7b859f7..a2de5f4a27 100644 --- a/plugins/auth-node/src/oauth/index.ts +++ b/plugins/auth-node/src/oauth/index.ts @@ -18,7 +18,12 @@ export { createOAuthHandlers, type OAuthHandlersOptions, } from './createOAuthHandlers'; -export { PassportOAuthAuthenticatorHelper } from './PassportOAuthAuthenticatorHelper'; +export { + PassportOAuthAuthenticatorHelper, + type PassportOAuthDoneCallback, + type PassportOAuthPrivateInfo, + type PassportOAuthResult, +} from './PassportOAuthAuthenticatorHelper'; export { createOAuthAuthenticator, type OAuthAuthenticator, From 3c1df5d4a957caa1e67ed837a3f00b7029ebdfa2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 29 Jul 2023 11:19:31 +0200 Subject: [PATCH 22/66] auth-node: createOAuthHandleres -> createOAuthRouteHandlers + refactor state transform Signed-off-by: Patrik Oldsberg --- ...andlers.ts => createOAuthRouteHandlers.ts} | 26 +++++++++++-------- plugins/auth-node/src/oauth/index.ts | 6 ++--- plugins/auth-node/src/oauth/state.ts | 21 ++++++--------- 3 files changed, 26 insertions(+), 27 deletions(-) rename plugins/auth-node/src/oauth/{createOAuthHandlers.ts => createOAuthRouteHandlers.ts} (93%) diff --git a/plugins/auth-node/src/oauth/createOAuthHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts similarity index 93% rename from plugins/auth-node/src/oauth/createOAuthHandlers.ts rename to plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts index e4fd3e5a50..fa6cb57e31 100644 --- a/plugins/auth-node/src/oauth/createOAuthHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts @@ -23,7 +23,12 @@ import { isError, NotAllowedError, } from '@backstage/errors'; -import { defaultStateDecoder, defaultStateEncoder, OAuthState } from './state'; +import { + encodeOAuthState, + decodeOAuthState, + OAuthStateTransform, + OAuthState, +} from './state'; import { sendWebMessageResponse } from '../flow'; import { prepareBackstageIdentityResponse } from '../identity'; import { OAuthCookieManager } from './OAuthCookieManager'; @@ -42,15 +47,15 @@ import { import { Config } from '@backstage/config'; /** @public */ -export interface OAuthHandlersOptions { - authenticator: OAuthAuthenticator; +export interface OAuthRouteHandlersOptions { + authenticator: OAuthAuthenticator; appUrl: string; baseUrl: string; isOriginAllowed: (origin: string) => boolean; - callbackUrl: string; providerId: string; config: Config; resolverContext: AuthResolverContext; + stateTransform?: OAuthStateTransform; profileTransform?: OAuthProfileTransform; cookieConfigurer?: CookieConfigurer; signInResolver?: SignInResolver>; @@ -77,8 +82,8 @@ type ClientOAuthResponse = ClientAuthResponse<{ }>; /** @public */ -export function createOAuthHandlers( - options: OAuthHandlersOptions, +export function createOAuthRouteHandlers( + options: OAuthRouteHandlersOptions, ): AuthProviderRouteHandlers { const { authenticator, @@ -97,6 +102,7 @@ export function createOAuthHandlers( 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 }); @@ -137,7 +143,8 @@ export function createOAuthHandlers( state.scope = scope; } - const { encodedState } = await defaultStateEncoder(state, { req }); + const { state: transformedState } = await stateTransform(state, { req }); + const encodedState = encodeOAuthState(transformedState); const { url, status } = await options.authenticator.start( { req, scope, state: encodedState }, @@ -158,10 +165,7 @@ export function createOAuthHandlers( let appOrigin = defaultAppOrigin; try { - const { state } = await defaultStateDecoder( - req.query.state?.toString() ?? '', - { req }, - ); + const state = decodeOAuthState(req.query.state?.toString() ?? ''); if (state.origin) { try { diff --git a/plugins/auth-node/src/oauth/index.ts b/plugins/auth-node/src/oauth/index.ts index a2de5f4a27..e07ee01a58 100644 --- a/plugins/auth-node/src/oauth/index.ts +++ b/plugins/auth-node/src/oauth/index.ts @@ -15,9 +15,9 @@ */ export { - createOAuthHandlers, - type OAuthHandlersOptions, -} from './createOAuthHandlers'; + createOAuthRouteHandlers, + type OAuthRouteHandlersOptions, +} from './createOAuthRouteHandlers'; export { PassportOAuthAuthenticatorHelper, type PassportOAuthDoneCallback, diff --git a/plugins/auth-node/src/oauth/state.ts b/plugins/auth-node/src/oauth/state.ts index 668fe3736a..71c5a2afb8 100644 --- a/plugins/auth-node/src/oauth/state.ts +++ b/plugins/auth-node/src/oauth/state.ts @@ -17,6 +17,7 @@ import pickBy from 'lodash/pickBy'; import { Request } from 'express'; +/** @public */ export type OAuthState = { /* A type for the serialized value in the `state` parameter of the OAuth authorization flow */ @@ -29,28 +30,22 @@ export type OAuthState = { }; /** @public */ -export type OAuthStateEncoder = ( +export type OAuthStateTransform = ( state: OAuthState, context: { req: Request }, -) => Promise<{ encodedState: string }>; - -/** @public */ -export type OAuthStateDecoder = ( - encodedState: string, - context: { req: Request }, ) => Promise<{ state: OAuthState }>; /** @public */ -export const defaultStateEncoder: OAuthStateEncoder = async state => { +export function encodeOAuthState(state: OAuthState): string { const stateString = new URLSearchParams( pickBy(state, value => value !== undefined), ).toString(); - return { encodedState: Buffer.from(stateString, 'utf-8').toString('hex') }; -}; + return Buffer.from(stateString, 'utf-8').toString('hex'); +} /** @public */ -export const defaultStateDecoder: OAuthStateDecoder = async encodedState => { +export function decodeOAuthState(encodedState: string): OAuthState { const state = Object.fromEntries( new URLSearchParams(Buffer.from(encodedState, 'hex').toString('utf-8')), ); @@ -63,5 +58,5 @@ export const defaultStateDecoder: OAuthStateDecoder = async encodedState => { throw Error(`Invalid state passed via request`); } - return { state: state as OAuthState }; -}; + return state as OAuthState; +} From 112e45e37fd4b461abf16fa8977ccc065db6d5e4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 29 Jul 2023 11:24:20 +0200 Subject: [PATCH 23/66] auth-backend: move OAuthEnvironmentHandler to auth-node Signed-off-by: Patrik Oldsberg --- .../src/lib/oauth/OAuthEnvironmentHandler.ts | 86 ++-------------- .../src/oauth/OAuthEnvironmentHandler.ts | 97 +++++++++++++++++++ plugins/auth-node/src/oauth/index.ts | 1 + 3 files changed, 104 insertions(+), 80 deletions(-) create mode 100644 plugins/auth-node/src/oauth/OAuthEnvironmentHandler.ts diff --git a/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts b/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts index 1c6d45d0a4..eef85f4e34 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts @@ -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(); - - 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, - ) {} - - async start(req: express.Request, res: express.Response): Promise { - const provider = this.getProviderForEnv(req); - await provider.start(req, res); - } - - async frameHandler( - req: express.Request, - res: express.Response, - ): Promise { - const provider = this.getProviderForEnv(req); - await provider.frameHandler(req, res); - } - - async refresh(req: express.Request, res: express.Response): Promise { - const provider = this.getProviderForEnv(req); - await provider.refresh?.(req, res); - } - - async logout(req: express.Request, res: express.Response): Promise { - 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 type OAuthEnvironmentHandler = _OAuthEnvironmentHandler; diff --git a/plugins/auth-node/src/oauth/OAuthEnvironmentHandler.ts b/plugins/auth-node/src/oauth/OAuthEnvironmentHandler.ts new file mode 100644 index 0000000000..3a5ee00f8b --- /dev/null +++ b/plugins/auth-node/src/oauth/OAuthEnvironmentHandler.ts @@ -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(); + + 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, + ) {} + + async start(req: express.Request, res: express.Response): Promise { + const provider = this.getProviderForEnv(req); + await provider.start(req, res); + } + + async frameHandler( + req: express.Request, + res: express.Response, + ): Promise { + const provider = this.getProviderForEnv(req); + await provider.frameHandler(req, res); + } + + async refresh(req: express.Request, res: express.Response): Promise { + const provider = this.getProviderForEnv(req); + await provider.refresh?.(req, res); + } + + async logout(req: express.Request, res: express.Response): Promise { + 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; + } +} diff --git a/plugins/auth-node/src/oauth/index.ts b/plugins/auth-node/src/oauth/index.ts index e07ee01a58..046d7a47f7 100644 --- a/plugins/auth-node/src/oauth/index.ts +++ b/plugins/auth-node/src/oauth/index.ts @@ -24,6 +24,7 @@ export { type PassportOAuthPrivateInfo, type PassportOAuthResult, } from './PassportOAuthAuthenticatorHelper'; +export { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler'; export { createOAuthAuthenticator, type OAuthAuthenticator, From 987637d75a322e5bec2d745cfc21a306f51338a8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 29 Jul 2023 11:31:00 +0200 Subject: [PATCH 24/66] auth-node: added createOAuthProviderFactory Signed-off-by: Patrik Oldsberg --- .../src/oauth/createOAuthProviderFactory.ts | 51 +++++++++++++++++++ plugins/auth-node/src/oauth/index.ts | 1 + 2 files changed, 52 insertions(+) create mode 100644 plugins/auth-node/src/oauth/createOAuthProviderFactory.ts diff --git a/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts b/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts new file mode 100644 index 0000000000..6120bb528a --- /dev/null +++ b/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts @@ -0,0 +1,51 @@ +/* + * 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 { AuthProviderFactory, SignInResolver } from '../types'; +import { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler'; +import { createOAuthRouteHandlers } from './createOAuthRouteHandlers'; +import { OAuthStateTransform } from './state'; +import { + OAuthAuthenticator, + OAuthAuthenticatorResult, + OAuthProfileTransform, +} from './types'; + +/** @public */ +export function createOAuthProviderFactory(options: { + authenticator: OAuthAuthenticator; + stateTransform?: OAuthStateTransform; + profileTransform?: OAuthProfileTransform; + signInResolver?: SignInResolver>; +}): AuthProviderFactory { + return ctx => { + return OAuthEnvironmentHandler.mapConfig(ctx.config, envConfig => { + return createOAuthRouteHandlers({ + authenticator: options.authenticator, + appUrl: ctx.globalConfig.appUrl, + baseUrl: ctx.globalConfig.baseUrl, + config: envConfig, + isOriginAllowed: ctx.globalConfig.isOriginAllowed, + cookieConfigurer: ctx.globalConfig.cookieConfigurer, + providerId: ctx.providerId, + resolverContext: ctx.resolverContext, + stateTransform: options.stateTransform, + profileTransform: options.profileTransform, + signInResolver: options.signInResolver, + }); + }); + }; +} diff --git a/plugins/auth-node/src/oauth/index.ts b/plugins/auth-node/src/oauth/index.ts index 046d7a47f7..99f05b0d0c 100644 --- a/plugins/auth-node/src/oauth/index.ts +++ b/plugins/auth-node/src/oauth/index.ts @@ -25,6 +25,7 @@ export { type PassportOAuthResult, } from './PassportOAuthAuthenticatorHelper'; export { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler'; +export { createOAuthProviderFactory } from './createOAuthProviderFactory'; export { createOAuthAuthenticator, type OAuthAuthenticator, From afdfeb797457e049dc424452b50e638d1271009b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 29 Jul 2023 11:35:24 +0200 Subject: [PATCH 25/66] auth-backend: deprecate OAuthAdapter Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index fb0750f0cb..0e0fb2b90f 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -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 + */ 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, From 63484f54c6bc57d62d55a7be0277b63ba31e59dc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 29 Jul 2023 11:38:55 +0200 Subject: [PATCH 26/66] auth-node: export OAuth state helpers and types Signed-off-by: Patrik Oldsberg --- plugins/auth-node/src/oauth/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/auth-node/src/oauth/index.ts b/plugins/auth-node/src/oauth/index.ts index 99f05b0d0c..ff07a1e6a8 100644 --- a/plugins/auth-node/src/oauth/index.ts +++ b/plugins/auth-node/src/oauth/index.ts @@ -26,6 +26,12 @@ export { } from './PassportOAuthAuthenticatorHelper'; export { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler'; export { createOAuthProviderFactory } from './createOAuthProviderFactory'; +export { + encodeOAuthState, + decodeOAuthState, + type OAuthState, + type OAuthStateTransform, +} from './state'; export { createOAuthAuthenticator, type OAuthAuthenticator, From 969f9f255387a44ccc490b7c0e384d5107efd5a4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 29 Jul 2023 11:43:45 +0200 Subject: [PATCH 27/66] auth-backend: deprecate OAuth types Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/src/lib/oauth/types.ts | 50 ++++++++++++--------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/plugins/auth-backend/src/lib/oauth/types.ts b/plugins/auth-backend/src/lib/oauth/types.ts index 46009218a9..e91f3819c4 100644 --- a/plugins/auth-backend/src/lib/oauth/types.ts +++ b/plugins/auth-backend/src/lib/oauth/types.ts @@ -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 */ export type OAuthProviderOptions = { /** @@ -39,7 +43,10 @@ export type OAuthProviderOptions = { callbackUrl: string; }; -/** @public */ +/** + * @public + * @deprecated Use `OAuthAuthenticatorResult` from `@backstage/plugin-auth-node` instead + */ export type OAuthResult = { fullProfile: PassportProfile; params: { @@ -53,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; @@ -83,41 +89,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 { /** From 12b4d8a3f8cc585a5fcd0a0ac0bf543ef9e12703 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 29 Jul 2023 11:56:38 +0200 Subject: [PATCH 28/66] auth-node: deprecate AuthProviderConfig and move to top-level props instead Signed-off-by: Patrik Oldsberg --- .../src/oauth/createOAuthProviderFactory.ts | 8 ++--- plugins/auth-node/src/types.ts | 30 +++++++++++++++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts b/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts index 6120bb528a..282fa6fad9 100644 --- a/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts +++ b/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts @@ -35,11 +35,11 @@ export function createOAuthProviderFactory(options: { return OAuthEnvironmentHandler.mapConfig(ctx.config, envConfig => { return createOAuthRouteHandlers({ authenticator: options.authenticator, - appUrl: ctx.globalConfig.appUrl, - baseUrl: ctx.globalConfig.baseUrl, + appUrl: ctx.appUrl, + baseUrl: ctx.baseUrl, config: envConfig, - isOriginAllowed: ctx.globalConfig.isOriginAllowed, - cookieConfigurer: ctx.globalConfig.cookieConfigurer, + isOriginAllowed: ctx.isOriginAllowed, + cookieConfigurer: ctx.cookieConfigurer, providerId: ctx.providerId, resolverContext: ctx.resolverContext, stateTransform: options.stateTransform, diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts index 409ad71ff9..f1d7063700 100644 --- a/plugins/auth-node/src/types.ts +++ b/plugins/auth-node/src/types.ts @@ -215,7 +215,10 @@ export interface AuthProviderRouteHandlers { logout?(req: Request, res: Response): Promise; } -/** @public */ +/** + * @public + * @deprecated Use top-level properties passed to `AuthProviderFactory` instead + */ export type AuthProviderConfig = { /** * The protocol://domain[:port] where the app is hosted. This is used to construct the @@ -242,15 +245,36 @@ export type AuthProviderConfig = { /** @public */ export type AuthProviderFactory = (options: { providerId: string; + /** @deprecated Use top-level properties instead */ globalConfig: AuthProviderConfig; config: Config; logger: LoggerService; resolverContext: AuthResolverContext; + /** + * 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; }) => AuthProviderRouteHandlers; /** @public */ -export type ClientAuthResponse = { - providerInfo: ProviderInfo; +export type ClientAuthResponse = { + providerInfo: TProviderInfo; profile: ProfileInfo; backstageIdentity?: BackstageIdentityResponse; }; From 39e19858b8454b0be3c625b7f390611a8c83394a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 2 Aug 2023 10:07:22 +0200 Subject: [PATCH 29/66] auth-node: add sign-in resolver factory Signed-off-by: Patrik Oldsberg --- plugins/auth-node/package.json | 4 +- .../sign-in/createSignInResolverFactory.ts | 71 +++++++++++++++++++ plugins/auth-node/src/sign-in/index.ts | 17 +++++ yarn.lock | 12 ++-- 4 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 plugins/auth-node/src/sign-in/createSignInResolverFactory.ts create mode 100644 plugins/auth-node/src/sign-in/index.ts diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index 92ad426a11..b0c503c144 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -41,7 +41,9 @@ "lodash": "^4.17.21", "node-fetch": "^2.6.7", "passport": "^0.6.0", - "winston": "^3.2.1" + "winston": "^3.2.1", + "zod": "^3.21.4", + "zod-to-json-schema": "^3.21.4" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts b/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts new file mode 100644 index 0000000000..25e65754fe --- /dev/null +++ b/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts @@ -0,0 +1,71 @@ +/* + * 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'; + +export interface SignInResolverFactory { + ( + ...options: undefined extends TOptions + ? [options?: TOptions] + : [options: TOptions] + ): SignInResolver; + optionsJsonSchema?: JsonObject; +} + +export interface SignInResolverFactoryOptions< + TAuthResult, + TOptionsOutput, + TOptionsInput, +> { + optionsSchema?: ZodSchema; + create(options: TOptionsOutput): SignInResolver; +} + +export function createSignInResolverFactory< + TAuthResult, + TOptionsOutput, + TOptionsInput, +>( + options: SignInResolverFactoryOptions< + TAuthResult, + TOptionsOutput, + TOptionsInput + >, +): SignInResolverFactory { + const { optionsSchema } = options; + if (!optionsSchema) { + return (resolverOptions?: TOptionsInput) => { + if (resolverOptions) { + throw new Error('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; +} diff --git a/plugins/auth-node/src/sign-in/index.ts b/plugins/auth-node/src/sign-in/index.ts new file mode 100644 index 0000000000..fd5b95b970 --- /dev/null +++ b/plugins/auth-node/src/sign-in/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { createSignInResolverFactory } from './createSignInResolverFactory'; diff --git a/yarn.lock b/yarn.lock index 7c10183f74..47a73df84f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4664,6 +4664,8 @@ __metadata: passport: ^0.6.0 uuid: ^8.0.0 winston: ^3.2.1 + zod: ^3.21.4 + zod-to-json-schema: ^3.21.4 languageName: unknown linkType: soft @@ -43089,12 +43091,12 @@ __metadata: languageName: node linkType: hard -"zod-to-json-schema@npm:^3.20.4": - version: 3.20.4 - resolution: "zod-to-json-schema@npm:3.20.4" +"zod-to-json-schema@npm:^3.20.4, zod-to-json-schema@npm:^3.21.4": + version: 3.21.4 + resolution: "zod-to-json-schema@npm:3.21.4" peerDependencies: - zod: ^3.20.0 - checksum: 55bc649dbc82ce25fbfbfdd42ca530d883d83be5d02cb81e89f6401d54bca151fc6b8cc57999c75eb6a3dcaa3f000697315fbd4f505637472621570ed52784d8 + zod: ^3.21.4 + checksum: 899c1f461fb6547c0b08a265c82040c250be9b88d3f408f2f3ff77a418fdfad7549077e589d418fccb312c1f6d555c3c7217b199cc9072762e1fab20716dd2a6 languageName: node linkType: hard From 861c5708c26784f4511511f582a1ddc486fb1352 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 2 Aug 2023 14:32:32 +0200 Subject: [PATCH 30/66] auth-node: add common sign-in resolvers Signed-off-by: Patrik Oldsberg --- .../src/sign-in/resolvers/byEmail.ts | 39 +++++++++++++++++++ .../src/sign-in/resolvers/byEmailLocalPart.ts | 38 ++++++++++++++++++ .../auth-node/src/sign-in/resolvers/index.ts | 18 +++++++++ 3 files changed, 95 insertions(+) create mode 100644 plugins/auth-node/src/sign-in/resolvers/byEmail.ts create mode 100644 plugins/auth-node/src/sign-in/resolvers/byEmailLocalPart.ts create mode 100644 plugins/auth-node/src/sign-in/resolvers/index.ts diff --git a/plugins/auth-node/src/sign-in/resolvers/byEmail.ts b/plugins/auth-node/src/sign-in/resolvers/byEmail.ts new file mode 100644 index 0000000000..0e087c7c7f --- /dev/null +++ b/plugins/auth-node/src/sign-in/resolvers/byEmail.ts @@ -0,0 +1,39 @@ +/* + * 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 common sign-in resolver that looks up the user using their email address + * as email of the entity. + */ +export const byEmail = 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, + }, + }); + }; + }, +}); diff --git a/plugins/auth-node/src/sign-in/resolvers/byEmailLocalPart.ts b/plugins/auth-node/src/sign-in/resolvers/byEmailLocalPart.ts new file mode 100644 index 0000000000..9328e7d6f7 --- /dev/null +++ b/plugins/auth-node/src/sign-in/resolvers/byEmailLocalPart.ts @@ -0,0 +1,38 @@ +/* + * 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 common sign-in resolver that looks up the user using the local part of + * their email address as the entity name. + */ +export const byEmailLocalPart = 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 }, + }); + }; + }, +}); diff --git a/plugins/auth-node/src/sign-in/resolvers/index.ts b/plugins/auth-node/src/sign-in/resolvers/index.ts new file mode 100644 index 0000000000..b3018391f2 --- /dev/null +++ b/plugins/auth-node/src/sign-in/resolvers/index.ts @@ -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 { byEmail } from './byEmail'; +export { byEmailLocalPart } from './byEmailLocalPart'; From bbeb816313fc52be24b0936422ba2d9147576e3c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 2 Aug 2023 14:35:05 +0200 Subject: [PATCH 31/66] add auth-backend-module-google-auth-provider Signed-off-by: Patrik Oldsberg --- .../.eslintrc.js | 1 + .../README.md | 5 ++ .../config.d.ts | 33 +++++++ .../package.json | 52 +++++++++++ .../src/authenticator.ts | 86 +++++++++++++++++++ .../src/index.ts | 18 ++++ .../src/module.ts | 42 +++++++++ yarn.lock | 16 ++++ 8 files changed, 253 insertions(+) create mode 100644 plugins/auth-backend-module-google-provider/.eslintrc.js create mode 100644 plugins/auth-backend-module-google-provider/README.md create mode 100644 plugins/auth-backend-module-google-provider/config.d.ts create mode 100644 plugins/auth-backend-module-google-provider/package.json create mode 100644 plugins/auth-backend-module-google-provider/src/authenticator.ts create mode 100644 plugins/auth-backend-module-google-provider/src/index.ts create mode 100644 plugins/auth-backend-module-google-provider/src/module.ts diff --git a/plugins/auth-backend-module-google-provider/.eslintrc.js b/plugins/auth-backend-module-google-provider/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-backend-module-google-provider/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-backend-module-google-provider/README.md b/plugins/auth-backend-module-google-provider/README.md new file mode 100644 index 0000000000..e031482f64 --- /dev/null +++ b/plugins/auth-backend-module-google-provider/README.md @@ -0,0 +1,5 @@ +# Auth Backend Module - Google Provider + +## Links + +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/auth-backend-module-google-provider/config.d.ts b/plugins/auth-backend-module-google-provider/config.d.ts new file mode 100644 index 0000000000..6a1716c5bf --- /dev/null +++ b/plugins/auth-backend-module-google-provider/config.d.ts @@ -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; + }; + }; + }; + }; +} diff --git a/plugins/auth-backend-module-google-provider/package.json b/plugins/auth-backend-module-google-provider/package.json new file mode 100644 index 0000000000..2104af5124 --- /dev/null +++ b/plugins/auth-backend-module-google-provider/package.json @@ -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" +} diff --git a/plugins/auth-backend-module-google-provider/src/authenticator.ts b/plugins/auth-backend-module-google-provider/src/authenticator.ts new file mode 100644 index 0000000000..a428519fee --- /dev/null +++ b/plugins/auth-backend-module-google-provider/src/authenticator.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); + } + }, +}); diff --git a/plugins/auth-backend-module-google-provider/src/index.ts b/plugins/auth-backend-module-google-provider/src/index.ts new file mode 100644 index 0000000000..81934894c2 --- /dev/null +++ b/plugins/auth-backend-module-google-provider/src/index.ts @@ -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 { googleAuthenticator } from './authenticator'; +export { authModuleGoogleProvider } from './module'; diff --git a/plugins/auth-backend-module-google-provider/src/module.ts b/plugins/auth-backend-module-google-provider/src/module.ts new file mode 100644 index 0000000000..95522b40f6 --- /dev/null +++ b/plugins/auth-backend-module-google-provider/src/module.ts @@ -0,0 +1,42 @@ +/* + * 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, + createOAuthProviderFactory, +} from '@backstage/plugin-auth-node'; +import { googleAuthenticator } from './authenticator'; + +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, + }), + }); + }, + }); + }, +}); diff --git a/yarn.lock b/yarn.lock index 47a73df84f..b974ad70d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4576,6 +4576,22 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-backend-module-google-provider@workspace:plugins/auth-backend-module-google-provider": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth-backend-module-google-provider@workspace:plugins/auth-backend-module-google-provider" + dependencies: + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + "@types/passport-google-oauth20": ^2.0.3 + google-auth-library: ^8.0.0 + msw: ^1.0.0 + passport-google-oauth20: ^2.0.0 + supertest: ^6.1.3 + languageName: unknown + linkType: soft + "@backstage/plugin-auth-backend@workspace:^, @backstage/plugin-auth-backend@workspace:plugins/auth-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend@workspace:plugins/auth-backend" From d30b4e387a94ea407ca92ce635182412daf5ccab Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Aug 2023 10:34:08 +0200 Subject: [PATCH 32/66] auth-node: add readDeclarativeSignInResolver Signed-off-by: Patrik Oldsberg --- .../src/oauth/createOAuthProviderFactory.ts | 16 ++++- plugins/auth-node/src/sign-in/index.ts | 4 ++ .../sign-in/readDeclarativeSignInResolver.ts | 69 +++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 plugins/auth-node/src/sign-in/readDeclarativeSignInResolver.ts diff --git a/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts b/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts index 282fa6fad9..e08756860a 100644 --- a/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts +++ b/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { readDeclarativeSignInResolver } from '../sign-in'; import { AuthProviderFactory, SignInResolver } from '../types'; import { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler'; import { createOAuthRouteHandlers } from './createOAuthRouteHandlers'; @@ -23,6 +24,7 @@ import { OAuthAuthenticatorResult, OAuthProfileTransform, } from './types'; +import { SignInResolverFactory } from '../sign-in/createSignInResolverFactory'; /** @public */ export function createOAuthProviderFactory(options: { @@ -30,9 +32,21 @@ export function createOAuthProviderFactory(options: { stateTransform?: OAuthStateTransform; profileTransform?: OAuthProfileTransform; signInResolver?: SignInResolver>; + signInResolverFactories?: { + [name in string]: SignInResolverFactory< + OAuthAuthenticatorResult, + unknown + >; + }; }): AuthProviderFactory { return ctx => { return OAuthEnvironmentHandler.mapConfig(ctx.config, envConfig => { + const signInResolver = + options.signInResolver ?? + readDeclarativeSignInResolver(envConfig, { + signInResolverFactories: options.signInResolverFactories, + }); + return createOAuthRouteHandlers({ authenticator: options.authenticator, appUrl: ctx.appUrl, @@ -44,7 +58,7 @@ export function createOAuthProviderFactory(options: { resolverContext: ctx.resolverContext, stateTransform: options.stateTransform, profileTransform: options.profileTransform, - signInResolver: options.signInResolver, + signInResolver, }); }); }; diff --git a/plugins/auth-node/src/sign-in/index.ts b/plugins/auth-node/src/sign-in/index.ts index fd5b95b970..05d0792ba4 100644 --- a/plugins/auth-node/src/sign-in/index.ts +++ b/plugins/auth-node/src/sign-in/index.ts @@ -15,3 +15,7 @@ */ export { createSignInResolverFactory } from './createSignInResolverFactory'; +export { + readDeclarativeSignInResolver, + type ReadDeclarativeSignInResolverOptions, +} from './readDeclarativeSignInResolver'; diff --git a/plugins/auth-node/src/sign-in/readDeclarativeSignInResolver.ts b/plugins/auth-node/src/sign-in/readDeclarativeSignInResolver.ts new file mode 100644 index 0000000000..31c4dd92a9 --- /dev/null +++ b/plugins/auth-node/src/sign-in/readDeclarativeSignInResolver.ts @@ -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 { Config } from '@backstage/config'; +import { JsonObject } from '@backstage/types'; +import { SignInResolver } from '../types'; +import { SignInResolverFactory } from './createSignInResolverFactory'; + +/** @public */ +export interface ReadDeclarativeSignInResolverOptions { + config: Config; + signInResolverFactories: { + [name in string]: SignInResolverFactory; + }; +} + +/** @public */ +export function readDeclarativeSignInResolver( + options: ReadDeclarativeSignInResolverOptions, +): SignInResolver | undefined { + const resolvers = + options.config + .getOptionalConfigArray('signIn.resolvers') + ?.map(resolverConfig => { + const resolverName = resolverConfig.getString('resolver'); + if (!Object.hasOwn(options.signInResolverFactories, resolverName)) { + throw new Error( + `Sign-in resolver '${resolverName}' is not available`, + ); + } + const resolver = options.signInResolverFactories[resolverName]; + const { resolver: _ignored, ...resolverOptions } = + resolverConfig.get(); + + return resolver(resolverOptions); + }) ?? []; + + if (resolvers.length === 0) { + return undefined; + } + + return async (profile, context) => { + for (const resolver of resolvers ?? []) { + try { + return await resolver(profile, context); + } catch (error) { + if (error?.name === 'NotFoundError') { + continue; + } + throw error; + } + } + + throw new Error('Failed to sign-in, unable to resolve user identity'); + }; +} From e14ad7c018e43b6e78d32860bf3bd6c87342dead Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Aug 2023 11:16:25 +0200 Subject: [PATCH 33/66] auth-node: fix OAuth redirect flow return Signed-off-by: Patrik Oldsberg --- plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts index fa6cb57e31..72640c8692 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts @@ -241,9 +241,11 @@ export function createOAuthRouteHandlers( ); } res.redirect(state.redirectUrl); + return; } + // post message back to popup if successful - return sendWebMessageResponse(res, appOrigin, { + sendWebMessageResponse(res, appOrigin, { type: 'authorization_response', response, }); @@ -252,7 +254,7 @@ export function createOAuthRouteHandlers( ? error : new Error('Encountered invalid error'); // Being a bit safe and not forwarding the bad value // post error message back to popup if failure - return sendWebMessageResponse(res, appOrigin, { + sendWebMessageResponse(res, appOrigin, { type: 'authorization_response', error: { name, message }, }); From f7b3d26cf49e03f80d53dca3bca125b8363807f9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Aug 2023 13:17:00 +0200 Subject: [PATCH 34/66] auth-node: export sign-in helpers and built-in resolver factories Signed-off-by: Patrik Oldsberg --- plugins/auth-node/src/index.ts | 1 + plugins/auth-node/src/sign-in/index.ts | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index e857f8af97..d528a3b3a7 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -25,6 +25,7 @@ export * from './flow'; export * from './identity'; export * from './oauth'; export * from './passport'; +export * from './sign-in'; export type { AuthProviderConfig, AuthProviderRouteHandlers, diff --git a/plugins/auth-node/src/sign-in/index.ts b/plugins/auth-node/src/sign-in/index.ts index 05d0792ba4..a560002e04 100644 --- a/plugins/auth-node/src/sign-in/index.ts +++ b/plugins/auth-node/src/sign-in/index.ts @@ -14,8 +14,13 @@ * limitations under the License. */ -export { createSignInResolverFactory } from './createSignInResolverFactory'; +export { + createSignInResolverFactory, + type SignInResolverFactory, + type SignInResolverFactoryOptions, +} from './createSignInResolverFactory'; export { readDeclarativeSignInResolver, type ReadDeclarativeSignInResolverOptions, } from './readDeclarativeSignInResolver'; +export * as commonSignInResolvers from './resolvers'; From acbf02aadaf0778ca5161827c75e086d63c56081 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Aug 2023 13:46:58 +0200 Subject: [PATCH 35/66] auth-node: refactor and rename common sign-in resolvers Signed-off-by: Patrik Oldsberg --- .../src/sign-in/commonSignInResolvers.ts | 73 +++++++++++++++++++ plugins/auth-node/src/sign-in/index.ts | 2 +- .../src/sign-in/resolvers/byEmail.ts | 39 ---------- .../src/sign-in/resolvers/byEmailLocalPart.ts | 38 ---------- .../auth-node/src/sign-in/resolvers/index.ts | 18 ----- 5 files changed, 74 insertions(+), 96 deletions(-) create mode 100644 plugins/auth-node/src/sign-in/commonSignInResolvers.ts delete mode 100644 plugins/auth-node/src/sign-in/resolvers/byEmail.ts delete mode 100644 plugins/auth-node/src/sign-in/resolvers/byEmailLocalPart.ts delete mode 100644 plugins/auth-node/src/sign-in/resolvers/index.ts diff --git a/plugins/auth-node/src/sign-in/commonSignInResolvers.ts b/plugins/auth-node/src/sign-in/commonSignInResolvers.ts new file mode 100644 index 0000000000..5b5da07c73 --- /dev/null +++ b/plugins/auth-node/src/sign-in/commonSignInResolvers.ts @@ -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 }, + }); + }; + }, + }); +} diff --git a/plugins/auth-node/src/sign-in/index.ts b/plugins/auth-node/src/sign-in/index.ts index a560002e04..736393a762 100644 --- a/plugins/auth-node/src/sign-in/index.ts +++ b/plugins/auth-node/src/sign-in/index.ts @@ -23,4 +23,4 @@ export { readDeclarativeSignInResolver, type ReadDeclarativeSignInResolverOptions, } from './readDeclarativeSignInResolver'; -export * as commonSignInResolvers from './resolvers'; +export { commonSignInResolvers } from './commonSignInResolvers'; diff --git a/plugins/auth-node/src/sign-in/resolvers/byEmail.ts b/plugins/auth-node/src/sign-in/resolvers/byEmail.ts deleted file mode 100644 index 0e087c7c7f..0000000000 --- a/plugins/auth-node/src/sign-in/resolvers/byEmail.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 common sign-in resolver that looks up the user using their email address - * as email of the entity. - */ -export const byEmail = 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, - }, - }); - }; - }, -}); diff --git a/plugins/auth-node/src/sign-in/resolvers/byEmailLocalPart.ts b/plugins/auth-node/src/sign-in/resolvers/byEmailLocalPart.ts deleted file mode 100644 index 9328e7d6f7..0000000000 --- a/plugins/auth-node/src/sign-in/resolvers/byEmailLocalPart.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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 common sign-in resolver that looks up the user using the local part of - * their email address as the entity name. - */ -export const byEmailLocalPart = 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 }, - }); - }; - }, -}); diff --git a/plugins/auth-node/src/sign-in/resolvers/index.ts b/plugins/auth-node/src/sign-in/resolvers/index.ts deleted file mode 100644 index b3018391f2..0000000000 --- a/plugins/auth-node/src/sign-in/resolvers/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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 { byEmail } from './byEmail'; -export { byEmailLocalPart } from './byEmailLocalPart'; From 2b8781affd5fd983ea4bf15ec528872ba911f154 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Aug 2023 13:51:31 +0200 Subject: [PATCH 36/66] auth-backend-module-google-provider: add sign-in resolvers Signed-off-by: Patrik Oldsberg --- .../src/index.ts | 1 + .../src/module.ts | 6 +++ .../src/resolvers.ts | 53 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 plugins/auth-backend-module-google-provider/src/resolvers.ts diff --git a/plugins/auth-backend-module-google-provider/src/index.ts b/plugins/auth-backend-module-google-provider/src/index.ts index 81934894c2..9999dd2bc5 100644 --- a/plugins/auth-backend-module-google-provider/src/index.ts +++ b/plugins/auth-backend-module-google-provider/src/index.ts @@ -16,3 +16,4 @@ export { googleAuthenticator } from './authenticator'; export { authModuleGoogleProvider } from './module'; +export { googleSignInResolvers } from './resolvers'; diff --git a/plugins/auth-backend-module-google-provider/src/module.ts b/plugins/auth-backend-module-google-provider/src/module.ts index 95522b40f6..fe067c223a 100644 --- a/plugins/auth-backend-module-google-provider/src/module.ts +++ b/plugins/auth-backend-module-google-provider/src/module.ts @@ -17,9 +17,11 @@ import { createBackendModule } from '@backstage/backend-plugin-api'; import { authProvidersExtensionPoint, + commonSignInResolvers, createOAuthProviderFactory, } from '@backstage/plugin-auth-node'; import { googleAuthenticator } from './authenticator'; +import { googleSignInResolvers } from './resolvers'; export const authModuleGoogleProvider = createBackendModule({ pluginId: 'auth', @@ -34,6 +36,10 @@ export const authModuleGoogleProvider = createBackendModule({ providerId: 'google', factory: createOAuthProviderFactory({ authenticator: googleAuthenticator, + signInResolverFactories: { + ...googleSignInResolvers, + ...commonSignInResolvers, + }, }), }); }, diff --git a/plugins/auth-backend-module-google-provider/src/resolvers.ts b/plugins/auth-backend-module-google-provider/src/resolvers.ts new file mode 100644 index 0000000000..b19fc4d49f --- /dev/null +++ b/plugins/auth-backend-module-google-provider/src/resolvers.ts @@ -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>, + ctx, + ) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('Google profile contained no email'); + } + + return ctx.signInWithCatalogUser({ + annotations: { + 'google.com/email': profile.email, + }, + }); + }; + }, + }); +} From d3265deba86e8e6dbe653c645a3a98632d5133f0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Aug 2023 17:43:17 +0200 Subject: [PATCH 37/66] auth-node: refactor to use plain ProfileTransform Signed-off-by: Patrik Oldsberg --- plugins/auth-node/src/index.ts | 1 + .../oauth/PassportOAuthAuthenticatorHelper.ts | 17 +++++++++-------- .../src/oauth/createOAuthProviderFactory.ts | 19 ++++++++++--------- .../src/oauth/createOAuthRouteHandlers.ts | 9 +++------ plugins/auth-node/src/oauth/types.ts | 9 ++------- plugins/auth-node/src/types.ts | 13 +++++++++++++ 6 files changed, 38 insertions(+), 30 deletions(-) diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index d528a3b3a7..a376c87338 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -38,6 +38,7 @@ export type { ClientAuthResponse, CookieConfigurer, ProfileInfo, + ProfileTransform, SignInInfo, SignInResolver, TokenParams, diff --git a/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts b/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts index c7fe81c3ee..420a661da5 100644 --- a/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts +++ b/plugins/auth-node/src/oauth/PassportOAuthAuthenticatorHelper.ts @@ -20,12 +20,12 @@ import { PassportHelpers, PassportProfile, } from '../passport'; +import { ProfileTransform } from '../types'; import { OAuthAuthenticatorAuthenticateInput, OAuthAuthenticatorRefreshInput, OAuthAuthenticatorResult, OAuthAuthenticatorStartInput, - OAuthProfileTransform, } from './types'; /** @public */ @@ -53,13 +53,14 @@ export type PassportOAuthDoneCallback = PassportDoneCallback< /** @public */ export class PassportOAuthAuthenticatorHelper { - static defaultProfileTransform: OAuthProfileTransform = - async input => ({ - profile: PassportHelpers.transformProfile( - input.fullProfile, - input.session.idToken, - ), - }); + static defaultProfileTransform: ProfileTransform< + OAuthAuthenticatorResult + > = async input => ({ + profile: PassportHelpers.transformProfile( + input.fullProfile, + input.session.idToken, + ), + }); static from(strategy: Strategy) { return new PassportOAuthAuthenticatorHelper(strategy); diff --git a/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts b/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts index e08756860a..87fde6a1a2 100644 --- a/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts +++ b/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts @@ -15,22 +15,22 @@ */ import { readDeclarativeSignInResolver } from '../sign-in'; -import { AuthProviderFactory, SignInResolver } from '../types'; +import { + AuthProviderFactory, + ProfileTransform, + SignInResolver, +} from '../types'; import { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler'; import { createOAuthRouteHandlers } from './createOAuthRouteHandlers'; import { OAuthStateTransform } from './state'; -import { - OAuthAuthenticator, - OAuthAuthenticatorResult, - OAuthProfileTransform, -} from './types'; +import { OAuthAuthenticator, OAuthAuthenticatorResult } from './types'; import { SignInResolverFactory } from '../sign-in/createSignInResolverFactory'; /** @public */ export function createOAuthProviderFactory(options: { authenticator: OAuthAuthenticator; stateTransform?: OAuthStateTransform; - profileTransform?: OAuthProfileTransform; + profileTransform?: ProfileTransform>; signInResolver?: SignInResolver>; signInResolverFactories?: { [name in string]: SignInResolverFactory< @@ -43,8 +43,9 @@ export function createOAuthProviderFactory(options: { return OAuthEnvironmentHandler.mapConfig(ctx.config, envConfig => { const signInResolver = options.signInResolver ?? - readDeclarativeSignInResolver(envConfig, { - signInResolverFactories: options.signInResolverFactories, + readDeclarativeSignInResolver({ + config: envConfig, + signInResolverFactories: options.signInResolverFactories ?? {}, }); return createOAuthRouteHandlers({ diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts index 72640c8692..6c4819a320 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts @@ -37,13 +37,10 @@ import { AuthResolverContext, ClientAuthResponse, CookieConfigurer, + ProfileTransform, SignInResolver, } from '../types'; -import { - OAuthAuthenticator, - OAuthAuthenticatorResult, - OAuthProfileTransform, -} from './types'; +import { OAuthAuthenticator, OAuthAuthenticatorResult } from './types'; import { Config } from '@backstage/config'; /** @public */ @@ -56,7 +53,7 @@ export interface OAuthRouteHandlersOptions { config: Config; resolverContext: AuthResolverContext; stateTransform?: OAuthStateTransform; - profileTransform?: OAuthProfileTransform; + profileTransform?: ProfileTransform>; cookieConfigurer?: CookieConfigurer; signInResolver?: SignInResolver>; } diff --git a/plugins/auth-node/src/oauth/types.ts b/plugins/auth-node/src/oauth/types.ts index 4a53beaae6..e6ddf370b6 100644 --- a/plugins/auth-node/src/oauth/types.ts +++ b/plugins/auth-node/src/oauth/types.ts @@ -16,7 +16,7 @@ import { Config } from '@backstage/config'; import { Request } from 'express'; -import { AuthResolverContext, ProfileInfo } from '../types'; +import { ProfileTransform } from '../types'; export interface OAuthSession { accessToken: string; @@ -27,11 +27,6 @@ export interface OAuthSession { refreshToken?: string; } -export type OAuthProfileTransform = ( - result: OAuthAuthenticatorResult, - context: AuthResolverContext, -) => Promise<{ profile: ProfileInfo }>; - export interface OAuthAuthenticatorStartInput { scope: string; state: string; @@ -60,7 +55,7 @@ export interface OAuthAuthenticatorResult { } export interface OAuthAuthenticator { - defaultProfileTransform: OAuthProfileTransform; + defaultProfileTransform: ProfileTransform>; shouldPersistScopes?: boolean; initialize(ctx: { callbackUrl: string; config: Config }): TContext; start( diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts index f1d7063700..ccdfb0c39b 100644 --- a/plugins/auth-node/src/types.ts +++ b/plugins/auth-node/src/types.ts @@ -309,6 +309,19 @@ export type SignInResolver = ( context: AuthResolverContext, ) => Promise; +/** + * Describes the function that transforms the result of a successful + * authentication into a {@link ProfileInfo} object. + * + * This function may optionally throw an error in order to reject authentication. + * + * @public + */ +export type ProfileTransform = ( + result: TResult, + context: AuthResolverContext, +) => Promise<{ profile: ProfileInfo }>; + /** * Used to display login information to user, i.e. sidebar popup. * From c3aa1b91e12b4960165f30707b775750ec956109 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Aug 2023 17:44:37 +0200 Subject: [PATCH 38/66] auth-node: add proxy provider APIs Signed-off-by: Patrik Oldsberg --- plugins/auth-node/src/index.ts | 1 + .../proxy/createProxyAuthProviderFactory.ts | 60 ++++++++++++++ .../src/proxy/createProxyRouteHandlers.ts | 79 +++++++++++++++++++ plugins/auth-node/src/proxy/index.ts | 22 ++++++ plugins/auth-node/src/proxy/types.ts | 34 ++++++++ 5 files changed, 196 insertions(+) create mode 100644 plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts create mode 100644 plugins/auth-node/src/proxy/createProxyRouteHandlers.ts create mode 100644 plugins/auth-node/src/proxy/index.ts create mode 100644 plugins/auth-node/src/proxy/types.ts diff --git a/plugins/auth-node/src/index.ts b/plugins/auth-node/src/index.ts index a376c87338..e7b0b628b8 100644 --- a/plugins/auth-node/src/index.ts +++ b/plugins/auth-node/src/index.ts @@ -25,6 +25,7 @@ export * from './flow'; export * from './identity'; export * from './oauth'; export * from './passport'; +export * from './proxy'; export * from './sign-in'; export type { AuthProviderConfig, diff --git a/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts b/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts new file mode 100644 index 0000000000..a50225e911 --- /dev/null +++ b/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts @@ -0,0 +1,60 @@ +/* + * 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'; + +export function createProxyAuthProviderFactory(options: { + authenticator: ProxyAuthenticator; + profileTransform?: ProfileTransform; + signInResolver?: SignInResolver; + signInResolverFactories?: Record< + string, + SignInResolverFactory + >; +}): 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({ + signInResolver, + config: ctx.config, + authenticator: options.authenticator, + resolverContext: ctx.resolverContext, + profileTransform: options.profileTransform, + }); + }; +} diff --git a/plugins/auth-node/src/proxy/createProxyRouteHandlers.ts b/plugins/auth-node/src/proxy/createProxyRouteHandlers.ts new file mode 100644 index 0000000000..10ad62d2bf --- /dev/null +++ b/plugins/auth-node/src/proxy/createProxyRouteHandlers.ts @@ -0,0 +1,79 @@ +/* + * 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'; + +/** @public */ +export interface ProxyAuthRouteHandlersOptions { + authenticator: ProxyAuthenticator; + config: Config; + resolverContext: AuthResolverContext; + signInResolver: SignInResolver; + profileTransform?: ProfileTransform; +} + +/** @public */ +export function createProxyAuthRouteHandlers( + options: ProxyAuthRouteHandlersOptions, +): AuthProviderRouteHandlers { + const { authenticator, config, resolverContext, signInResolver } = options; + + const profileTransform = + options.profileTransform ?? authenticator.defaultProfileTransform; + const authenticatorCtx = authenticator.initialize({ config }); + + return { + async start(): Promise { + throw new Error('Not implemented'); + }, + + async frameHandler(): Promise { + throw new Error('Not implemented'); + }, + + async refresh(this: never, req: Request, res: Response): Promise { + 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); + }, + }; +} diff --git a/plugins/auth-node/src/proxy/index.ts b/plugins/auth-node/src/proxy/index.ts new file mode 100644 index 0000000000..907f8829e5 --- /dev/null +++ b/plugins/auth-node/src/proxy/index.ts @@ -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'; diff --git a/plugins/auth-node/src/proxy/types.ts b/plugins/auth-node/src/proxy/types.ts new file mode 100644 index 0000000000..a7e0da2d4c --- /dev/null +++ b/plugins/auth-node/src/proxy/types.ts @@ -0,0 +1,34 @@ +/* + * 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'; + +export interface ProxyAuthenticator { + defaultProfileTransform: ProfileTransform; + initialize(ctx: { config: Config }): Promise; + authenticate( + options: { req: Request }, + ctx: TContext, + ): Promise<{ result: TResult }>; +} + +export function createProxyAuthenticator( + authenticator: ProxyAuthenticator, +): ProxyAuthenticator { + return authenticator; +} From 2f214950a38e45e2dbae7588b03b31998ede4d37 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Aug 2023 17:45:58 +0200 Subject: [PATCH 39/66] add auth-backend-module-iap-provider Signed-off-by: Patrik Oldsberg --- .../.eslintrc.js | 1 + .../README.md | 5 + .../config.d.ts | 29 ++++ .../package.json | 52 +++++++ .../src/authenticator.ts | 52 +++++++ .../src/helpers.test.ts | 124 ++++++++++++++++ .../src/helpers.ts | 67 +++++++++ .../src/index.ts | 19 +++ .../src/module.ts | 48 +++++++ .../src/resolvers.ts | 49 +++++++ .../src/types.ts | 50 +++++++ .../src/providers/gcp-iap/helpers.test.ts | 134 ------------------ .../src/providers/gcp-iap/helpers.ts | 82 ----------- yarn.lock | 16 +++ 14 files changed, 512 insertions(+), 216 deletions(-) create mode 100644 plugins/auth-backend-module-gcp-iap-provider/.eslintrc.js create mode 100644 plugins/auth-backend-module-gcp-iap-provider/README.md create mode 100644 plugins/auth-backend-module-gcp-iap-provider/config.d.ts create mode 100644 plugins/auth-backend-module-gcp-iap-provider/package.json create mode 100644 plugins/auth-backend-module-gcp-iap-provider/src/authenticator.ts create mode 100644 plugins/auth-backend-module-gcp-iap-provider/src/helpers.test.ts create mode 100644 plugins/auth-backend-module-gcp-iap-provider/src/helpers.ts create mode 100644 plugins/auth-backend-module-gcp-iap-provider/src/index.ts create mode 100644 plugins/auth-backend-module-gcp-iap-provider/src/module.ts create mode 100644 plugins/auth-backend-module-gcp-iap-provider/src/resolvers.ts create mode 100644 plugins/auth-backend-module-gcp-iap-provider/src/types.ts delete mode 100644 plugins/auth-backend/src/providers/gcp-iap/helpers.test.ts delete mode 100644 plugins/auth-backend/src/providers/gcp-iap/helpers.ts diff --git a/plugins/auth-backend-module-gcp-iap-provider/.eslintrc.js b/plugins/auth-backend-module-gcp-iap-provider/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-backend-module-gcp-iap-provider/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-backend-module-gcp-iap-provider/README.md b/plugins/auth-backend-module-gcp-iap-provider/README.md new file mode 100644 index 0000000000..a5f3ce1d4c --- /dev/null +++ b/plugins/auth-backend-module-gcp-iap-provider/README.md @@ -0,0 +1,5 @@ +# Auth Backend Module - GCP IAP Provider + +## Links + +- [The Backstage homepage](https://backstage.io) diff --git a/plugins/auth-backend-module-gcp-iap-provider/config.d.ts b/plugins/auth-backend-module-gcp-iap-provider/config.d.ts new file mode 100644 index 0000000000..c8460470af --- /dev/null +++ b/plugins/auth-backend-module-gcp-iap-provider/config.d.ts @@ -0,0 +1,29 @@ +/* + * 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?: { + gcpIap?: { + [authEnv: string]: { + audience: string; + + jwtHeader?: string; + }; + }; + }; + }; +} diff --git a/plugins/auth-backend-module-gcp-iap-provider/package.json b/plugins/auth-backend-module-gcp-iap-provider/package.json new file mode 100644 index 0000000000..93c47f2706 --- /dev/null +++ b/plugins/auth-backend-module-gcp-iap-provider/package.json @@ -0,0 +1,52 @@ +{ + "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:^", + "msw": "^1.0.0", + "supertest": "^6.1.3" + }, + "files": [ + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" +} diff --git a/plugins/auth-backend-module-gcp-iap-provider/src/authenticator.ts b/plugins/auth-backend-module-gcp-iap-provider/src/authenticator.ts new file mode 100644 index 0000000000..ca104ded86 --- /dev/null +++ b/plugins/auth-backend-module-gcp-iap-provider/src/authenticator.ts @@ -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 } }; + }, +}); diff --git a/plugins/auth-backend-module-gcp-iap-provider/src/helpers.test.ts b/plugins/auth-backend-module-gcp-iap-provider/src/helpers.test.ts new file mode 100644 index 0000000000..4079f8f7d3 --- /dev/null +++ b/plugins/auth-backend-module-gcp-iap-provider/src/helpers.test.ts @@ -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', + ); + }); + }); +}); diff --git a/plugins/auth-backend-module-gcp-iap-provider/src/helpers.ts b/plugins/auth-backend-module-gcp-iap-provider/src/helpers.ts new file mode 100644 index 0000000000..54db7117f9 --- /dev/null +++ b/plugins/auth-backend-module-gcp-iap-provider/src/helpers.ts @@ -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 { + 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; + }; +} diff --git a/plugins/auth-backend-module-gcp-iap-provider/src/index.ts b/plugins/auth-backend-module-gcp-iap-provider/src/index.ts new file mode 100644 index 0000000000..50ea1cb2af --- /dev/null +++ b/plugins/auth-backend-module-gcp-iap-provider/src/index.ts @@ -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 { gcpIapAuthenticator } from './authenticator'; +export { authModuleGcpIapProvider } from './module'; +export { gcpIapSignInResolvers } from './resolvers'; diff --git a/plugins/auth-backend-module-gcp-iap-provider/src/module.ts b/plugins/auth-backend-module-gcp-iap-provider/src/module.ts new file mode 100644 index 0000000000..38742e4236 --- /dev/null +++ b/plugins/auth-backend-module-gcp-iap-provider/src/module.ts @@ -0,0 +1,48 @@ +/* + * 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'; + +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, + }, + }), + }); + }, + }); + }, +}); diff --git a/plugins/auth-backend-module-gcp-iap-provider/src/resolvers.ts b/plugins/auth-backend-module-gcp-iap-provider/src/resolvers.ts new file mode 100644 index 0000000000..32ab2b6cc7 --- /dev/null +++ b/plugins/auth-backend-module-gcp-iap-provider/src/resolvers.ts @@ -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, 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, + }, + }); + }; + }, + }); +} diff --git a/plugins/auth-backend-module-gcp-iap-provider/src/types.ts b/plugins/auth-backend-module-gcp-iap-provider/src/types.ts new file mode 100644 index 0000000000..a967a4d579 --- /dev/null +++ b/plugins/auth-backend-module-gcp-iap-provider/src/types.ts @@ -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; +}; diff --git a/plugins/auth-backend/src/providers/gcp-iap/helpers.test.ts b/plugins/auth-backend/src/providers/gcp-iap/helpers.test.ts deleted file mode 100644 index 0162366f61..0000000000 --- a/plugins/auth-backend/src/providers/gcp-iap/helpers.test.ts +++ /dev/null @@ -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', - }); - }); - }); -}); diff --git a/plugins/auth-backend/src/providers/gcp-iap/helpers.ts b/plugins/auth-backend/src/providers/gcp-iap/helpers.ts deleted file mode 100644 index 26d9a8c904..0000000000 --- a/plugins/auth-backend/src/providers/gcp-iap/helpers.ts +++ /dev/null @@ -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 { - 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, -): Promise { - 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 = async ({ - iapToken, -}) => ({ profile: { email: iapToken.email } }); diff --git a/yarn.lock b/yarn.lock index b974ad70d3..48b1c3999a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4576,6 +4576,22 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-backend-module-gcp-iap-provider@workspace:plugins/auth-backend-module-gcp-iap-provider": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth-backend-module-gcp-iap-provider@workspace:plugins/auth-backend-module-gcp-iap-provider" + dependencies: + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + "@backstage/types": "workspace:^" + google-auth-library: ^8.0.0 + msw: ^1.0.0 + supertest: ^6.1.3 + languageName: unknown + linkType: soft + "@backstage/plugin-auth-backend-module-google-provider@workspace:plugins/auth-backend-module-google-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-google-provider@workspace:plugins/auth-backend-module-google-provider" From a4d47d29adeca8566b98d9d80c1d88b0fdb05adb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Aug 2023 19:26:03 +0200 Subject: [PATCH 40/66] auth-backend: more deprecations in lib Signed-off-by: Patrik Oldsberg --- .../src/lib/flow/authFlowHelpers.ts | 10 ++++- plugins/auth-backend/src/lib/flow/types.ts | 16 ++----- plugins/auth-backend/src/lib/oauth/helpers.ts | 44 ++++++++----------- 3 files changed, 29 insertions(+), 41 deletions(-) diff --git a/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts b/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts index 94ac4fba15..2f1770a3d4 100644 --- a/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts +++ b/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts @@ -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(``); }; -/** @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') { diff --git a/plugins/auth-backend/src/lib/flow/types.ts b/plugins/auth-backend/src/lib/flow/types.ts index f67198a1b0..36f3033b21 100644 --- a/plugins/auth-backend/src/lib/flow/types.ts +++ b/plugins/auth-backend/src/lib/flow/types.ts @@ -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; - } - | { - type: 'authorization_response'; - error: Error; - }; +export type WebMessageResponse = _WebMessageResponse; diff --git a/plugins/auth-backend/src/lib/oauth/helpers.ts b/plugins/auth-backend/src/lib/oauth/helpers.ts index 2b4e50b477..5e67b072e3 100644 --- a/plugins/auth-backend/src/lib/oauth/helpers.ts +++ b/plugins/auth-backend/src/lib/oauth/helpers.ts @@ -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(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() ?? ''); From 705ac88dcc91c0f9874c94c2c034f89aea118901 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Aug 2023 19:37:08 +0200 Subject: [PATCH 41/66] auth-backend: added legacy authHandler transform Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/src/index.ts | 3 + .../legacy/adaptLegacyOAuthHandler.test.ts | 61 +++++++++++++++++++ .../src/lib/legacy/adaptLegacyOAuthHandler.ts | 46 ++++++++++++++ plugins/auth-backend/src/lib/legacy/index.ts | 17 ++++++ 4 files changed, 127 insertions(+) create mode 100644 plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthHandler.test.ts create mode 100644 plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthHandler.ts create mode 100644 plugins/auth-backend/src/lib/legacy/index.ts diff --git a/plugins/auth-backend/src/index.ts b/plugins/auth-backend/src/index.ts index f8a8580359..cc6d460f3c 100644 --- a/plugins/auth-backend/src/index.ts +++ b/plugins/auth-backend/src/index.ts @@ -31,6 +31,9 @@ export * from './lib/flow'; // OAuth wrapper over a passport or a custom `strategy`. export * from './lib/oauth'; +// Helpers to convert new API in @backstage/plugin-auth-node to old API +export * from './lib/legacy'; + export * from './lib/catalog'; export { getDefaultOwnershipEntityRefs } from './lib/resolvers'; diff --git a/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthHandler.test.ts b/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthHandler.test.ts new file mode 100644 index 0000000000..5c5de6a890 --- /dev/null +++ b/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthHandler.test.ts @@ -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 = 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' }, + ); + }); +}); diff --git a/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthHandler.ts b/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthHandler.ts new file mode 100644 index 0000000000..3114306424 --- /dev/null +++ b/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthHandler.ts @@ -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'; + +/** @public */ +export function adaptLegacyOAuthHandler( + authHandler?: AuthHandler, +): ProfileTransform> | 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, + )) + ); +} diff --git a/plugins/auth-backend/src/lib/legacy/index.ts b/plugins/auth-backend/src/lib/legacy/index.ts new file mode 100644 index 0000000000..b05f9afdab --- /dev/null +++ b/plugins/auth-backend/src/lib/legacy/index.ts @@ -0,0 +1,17 @@ +/* + * 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'; From 7d29ca8e8c364c4e0de99fd8b505a3b8389c24ad Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Aug 2023 19:45:19 +0200 Subject: [PATCH 42/66] auth-backend: added legacy sign-in resolver transform Signed-off-by: Patrik Oldsberg --- .../adoptLegacyOAuthSignInResolver.test.ts | 69 +++++++++++++++++++ .../legacy/adoptLegacyOAuthSignInResolver.ts | 49 +++++++++++++ plugins/auth-backend/src/lib/legacy/index.ts | 1 + 3 files changed, 119 insertions(+) create mode 100644 plugins/auth-backend/src/lib/legacy/adoptLegacyOAuthSignInResolver.test.ts create mode 100644 plugins/auth-backend/src/lib/legacy/adoptLegacyOAuthSignInResolver.ts diff --git a/plugins/auth-backend/src/lib/legacy/adoptLegacyOAuthSignInResolver.test.ts b/plugins/auth-backend/src/lib/legacy/adoptLegacyOAuthSignInResolver.test.ts new file mode 100644 index 0000000000..ab18970918 --- /dev/null +++ b/plugins/auth-backend/src/lib/legacy/adoptLegacyOAuthSignInResolver.test.ts @@ -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 { adoptLegacyOAuthSignInResolver } from './adoptLegacyOAuthSignInResolver'; + +describe('adoptLegacyOAuthSignInResolver', () => { + it('should pass through undefined', () => { + expect(adoptLegacyOAuthSignInResolver(undefined)).toBeUndefined(); + }); + + it('should convert a legacy resolver to a new one', () => { + const legacyResolver = jest.fn(); + + const newResolver = adoptLegacyOAuthSignInResolver(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' }, + ); + }); +}); diff --git a/plugins/auth-backend/src/lib/legacy/adoptLegacyOAuthSignInResolver.ts b/plugins/auth-backend/src/lib/legacy/adoptLegacyOAuthSignInResolver.ts new file mode 100644 index 0000000000..594c296374 --- /dev/null +++ b/plugins/auth-backend/src/lib/legacy/adoptLegacyOAuthSignInResolver.ts @@ -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'; + +/** @public */ +export function adoptLegacyOAuthSignInResolver( + signInResolver?: SignInResolver, +): SignInResolver> | 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, + )) + ); +} diff --git a/plugins/auth-backend/src/lib/legacy/index.ts b/plugins/auth-backend/src/lib/legacy/index.ts index b05f9afdab..44bbae998c 100644 --- a/plugins/auth-backend/src/lib/legacy/index.ts +++ b/plugins/auth-backend/src/lib/legacy/index.ts @@ -15,3 +15,4 @@ */ export { adaptLegacyOAuthHandler } from './adaptLegacyOAuthHandler'; +export { adoptLegacyOAuthSignInResolver } from './adoptLegacyOAuthSignInResolver'; From 549dd6db1205a5e8bf9766ac253c97f86f091bbb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 10:04:59 +0200 Subject: [PATCH 43/66] auth-backend: added new to legacy sign-in resolvers transform Signed-off-by: Patrik Oldsberg --- .../adaptOAuthSignInResolverToLegacy.test.ts | 82 +++++++++++++++++++ .../adaptOAuthSignInResolverToLegacy.ts | 54 ++++++++++++ plugins/auth-backend/src/lib/legacy/index.ts | 1 + 3 files changed, 137 insertions(+) create mode 100644 plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.test.ts create mode 100644 plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.ts diff --git a/plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.test.ts b/plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.test.ts new file mode 100644 index 0000000000..37853d4840 --- /dev/null +++ b/plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.test.ts @@ -0,0 +1,82 @@ +/* + * 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()({} as any, {} as any); + expect(resolverB).toHaveBeenCalled(); + }); +}); diff --git a/plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.ts b/plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.ts new file mode 100644 index 0000000000..43b8fa2e9a --- /dev/null +++ b/plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.ts @@ -0,0 +1,54 @@ +/* + * 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'; + +export function adaptOAuthSignInResolverToLegacy< + TKeys extends string, +>(resolvers: { + [key in TKeys]: SignInResolver>; +}): { [key in TKeys]: () => SignInResolver } { + const legacyResolvers = {} as { + [key in TKeys]: () => SignInResolver; + }; + 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; +} diff --git a/plugins/auth-backend/src/lib/legacy/index.ts b/plugins/auth-backend/src/lib/legacy/index.ts index 44bbae998c..dcff046ded 100644 --- a/plugins/auth-backend/src/lib/legacy/index.ts +++ b/plugins/auth-backend/src/lib/legacy/index.ts @@ -16,3 +16,4 @@ export { adaptLegacyOAuthHandler } from './adaptLegacyOAuthHandler'; export { adoptLegacyOAuthSignInResolver } from './adoptLegacyOAuthSignInResolver'; +export { adaptOAuthSignInResolverToLegacy } from './adaptOAuthSignInResolverToLegacy'; From 23a8fa50251fcedf1344cb5213965c39d9c048ba Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 10:07:53 +0200 Subject: [PATCH 44/66] auth-backend: spelling fix Signed-off-by: Patrik Oldsberg --- ...ver.test.ts => adaptLegacyOAuthSignInResolver.test.ts} | 8 ++++---- ...ignInResolver.ts => adaptLegacyOAuthSignInResolver.ts} | 2 +- plugins/auth-backend/src/lib/legacy/index.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename plugins/auth-backend/src/lib/legacy/{adoptLegacyOAuthSignInResolver.test.ts => adaptLegacyOAuthSignInResolver.test.ts} (88%) rename plugins/auth-backend/src/lib/legacy/{adoptLegacyOAuthSignInResolver.ts => adaptLegacyOAuthSignInResolver.ts} (96%) diff --git a/plugins/auth-backend/src/lib/legacy/adoptLegacyOAuthSignInResolver.test.ts b/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthSignInResolver.test.ts similarity index 88% rename from plugins/auth-backend/src/lib/legacy/adoptLegacyOAuthSignInResolver.test.ts rename to plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthSignInResolver.test.ts index ab18970918..749cf96cbb 100644 --- a/plugins/auth-backend/src/lib/legacy/adoptLegacyOAuthSignInResolver.test.ts +++ b/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthSignInResolver.test.ts @@ -18,17 +18,17 @@ import { AuthResolverContext, PassportProfile, } from '@backstage/plugin-auth-node'; -import { adoptLegacyOAuthSignInResolver } from './adoptLegacyOAuthSignInResolver'; +import { adaptLegacyOAuthSignInResolver } from './adaptLegacyOAuthSignInResolver'; -describe('adoptLegacyOAuthSignInResolver', () => { +describe('adaptLegacyOAuthSignInResolver', () => { it('should pass through undefined', () => { - expect(adoptLegacyOAuthSignInResolver(undefined)).toBeUndefined(); + expect(adaptLegacyOAuthSignInResolver(undefined)).toBeUndefined(); }); it('should convert a legacy resolver to a new one', () => { const legacyResolver = jest.fn(); - const newResolver = adoptLegacyOAuthSignInResolver(legacyResolver); + const newResolver = adaptLegacyOAuthSignInResolver(legacyResolver); newResolver?.( { diff --git a/plugins/auth-backend/src/lib/legacy/adoptLegacyOAuthSignInResolver.ts b/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthSignInResolver.ts similarity index 96% rename from plugins/auth-backend/src/lib/legacy/adoptLegacyOAuthSignInResolver.ts rename to plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthSignInResolver.ts index 594c296374..02056c0517 100644 --- a/plugins/auth-backend/src/lib/legacy/adoptLegacyOAuthSignInResolver.ts +++ b/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthSignInResolver.ts @@ -22,7 +22,7 @@ import { import { OAuthResult } from '../oauth'; /** @public */ -export function adoptLegacyOAuthSignInResolver( +export function adaptLegacyOAuthSignInResolver( signInResolver?: SignInResolver, ): SignInResolver> | undefined { return ( diff --git a/plugins/auth-backend/src/lib/legacy/index.ts b/plugins/auth-backend/src/lib/legacy/index.ts index dcff046ded..8bd8b5c62e 100644 --- a/plugins/auth-backend/src/lib/legacy/index.ts +++ b/plugins/auth-backend/src/lib/legacy/index.ts @@ -15,5 +15,5 @@ */ export { adaptLegacyOAuthHandler } from './adaptLegacyOAuthHandler'; -export { adoptLegacyOAuthSignInResolver } from './adoptLegacyOAuthSignInResolver'; +export { adaptLegacyOAuthSignInResolver } from './adaptLegacyOAuthSignInResolver'; export { adaptOAuthSignInResolverToLegacy } from './adaptOAuthSignInResolverToLegacy'; From 647929a483e3e4702218fe4110cb45ef8272d36e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 10:12:07 +0200 Subject: [PATCH 45/66] auth-backend: migrate google provider to use new system Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/package.json | 1 + .../src/providers/google/provider.ts | 238 ++---------------- yarn.lock | 3 +- 3 files changed, 28 insertions(+), 214 deletions(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 6bbf6c901c..7d24f9d64d 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -38,6 +38,7 @@ "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/plugin-auth-backend-module-google-provider": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/types": "workspace:^", "@davidzemon/passport-okta-oauth": "^0.0.5", diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index afe5c27f6b..d467977094 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -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; - authHandler: AuthHandler; - resolverContext: AuthResolverContext; -}; - -export class GoogleAuthProvider implements OAuthHandlers { - private readonly strategy: GoogleStrategy; - private readonly signInResolver?: SignInResolver; - private readonly authHandler: AuthHandler; - 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, - ) => { - done( - undefined, - { - fullProfile, - params, - accessToken, - refreshToken, - }, - { - refreshToken, - }, - ); - }, - ); - } - - async start(req: OAuthStartRequest): Promise { - 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; }; }) { - 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 = 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 { - 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(), + }), }); diff --git a/yarn.lock b/yarn.lock index 48b1c3999a..9f9ef97744 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4592,7 +4592,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-backend-module-google-provider@workspace:plugins/auth-backend-module-google-provider": +"@backstage/plugin-auth-backend-module-google-provider@workspace:^, @backstage/plugin-auth-backend-module-google-provider@workspace:plugins/auth-backend-module-google-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-google-provider@workspace:plugins/auth-backend-module-google-provider" dependencies: @@ -4620,6 +4620,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/plugin-auth-backend-module-google-provider": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/types": "workspace:^" "@davidzemon/passport-okta-oauth": ^0.0.5 From 9aeb35adce9d8b30a0335f0bcac03899a09d9c37 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 11:14:14 +0200 Subject: [PATCH 46/66] auth-backend: migrate gcp-iap provider to use new system Signed-off-by: Patrik Oldsberg --- .../src/index.ts | 1 + plugins/auth-backend/package.json | 1 + .../src/providers/gcp-iap/provider.ts | 88 ++----------------- .../src/providers/gcp-iap/types.ts | 50 ++--------- yarn.lock | 3 +- 5 files changed, 21 insertions(+), 122 deletions(-) diff --git a/plugins/auth-backend-module-gcp-iap-provider/src/index.ts b/plugins/auth-backend-module-gcp-iap-provider/src/index.ts index 50ea1cb2af..be6626a815 100644 --- a/plugins/auth-backend-module-gcp-iap-provider/src/index.ts +++ b/plugins/auth-backend-module-gcp-iap-provider/src/index.ts @@ -17,3 +17,4 @@ export { gcpIapAuthenticator } from './authenticator'; export { authModuleGcpIapProvider } from './module'; export { gcpIapSignInResolvers } from './resolvers'; +export { type GcpIapResult, type GcpIapTokenInfo } from './types'; diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 7d24f9d64d..2cacfb67f1 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -38,6 +38,7 @@ "@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:^", diff --git a/plugins/auth-backend/src/providers/gcp-iap/provider.ts b/plugins/auth-backend/src/providers/gcp-iap/provider.ts index 7e77853737..db4d9195b2 100644 --- a/plugins/auth-backend/src/providers/gcp-iap/provider.ts +++ b/plugins/auth-backend/src/providers/gcp-iap/provider.ts @@ -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; - private readonly signInResolver: SignInResolver; - private readonly tokenValidator: (token: string) => Promise; - private readonly resolverContext: AuthResolverContext; - private readonly jwtHeader: string; - - constructor(options: { - authHandler: AuthHandler; - signInResolver: SignInResolver; - tokenValidator: (token: string) => Promise; - 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 { - 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; }; }) { - 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, + }); }, }); diff --git a/plugins/auth-backend/src/providers/gcp-iap/types.ts b/plugins/auth-backend/src/providers/gcp-iap/types.ts index 6519de64ac..0a88979323 100644 --- a/plugins/auth-backend/src/providers/gcp-iap/types.ts +++ b/plugins/auth-backend/src/providers/gcp-iap/types.ts @@ -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 */ -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; +export type GcpIapResult = _GcpIapResult; diff --git a/yarn.lock b/yarn.lock index 9f9ef97744..986fcb0c09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4576,7 +4576,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-backend-module-gcp-iap-provider@workspace:plugins/auth-backend-module-gcp-iap-provider": +"@backstage/plugin-auth-backend-module-gcp-iap-provider@workspace:^, @backstage/plugin-auth-backend-module-gcp-iap-provider@workspace:plugins/auth-backend-module-gcp-iap-provider": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend-module-gcp-iap-provider@workspace:plugins/auth-backend-module-gcp-iap-provider" dependencies: @@ -4620,6 +4620,7 @@ __metadata: "@backstage/cli": "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:^" From 0d078740e85dbdd2f8891a7076dbb5814ffe3982 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 11:30:49 +0200 Subject: [PATCH 47/66] auth-backend: move gcp-iap provider test Signed-off-by: Patrik Oldsberg --- .../package.json | 1 + .../src/authenticator.test.ts | 91 +++++++++++++++++++ .../src/providers/gcp-iap/provider.test.ts | 75 --------------- yarn.lock | 3 +- 4 files changed, 94 insertions(+), 76 deletions(-) create mode 100644 plugins/auth-backend-module-gcp-iap-provider/src/authenticator.test.ts delete mode 100644 plugins/auth-backend/src/providers/gcp-iap/provider.test.ts diff --git a/plugins/auth-backend-module-gcp-iap-provider/package.json b/plugins/auth-backend-module-gcp-iap-provider/package.json index 93c47f2706..9c073a6cb8 100644 --- a/plugins/auth-backend-module-gcp-iap-provider/package.json +++ b/plugins/auth-backend-module-gcp-iap-provider/package.json @@ -41,6 +41,7 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "express": "^4.18.2", "msw": "^1.0.0", "supertest": "^6.1.3" }, diff --git a/plugins/auth-backend-module-gcp-iap-provider/src/authenticator.test.ts b/plugins/auth-backend-module-gcp-iap-provider/src/authenticator.test.ts new file mode 100644 index 0000000000..51efbe1a04 --- /dev/null +++ b/plugins/auth-backend-module-gcp-iap-provider/src/authenticator.test.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'); + }); +}); diff --git a/plugins/auth-backend/src/providers/gcp-iap/provider.test.ts b/plugins/auth-backend/src/providers/gcp-iap/provider.test.ts deleted file mode 100644 index c01db3a853..0000000000 --- a/plugins/auth-backend/src/providers/gcp-iap/provider.test.ts +++ /dev/null @@ -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 }, - }); - }, - ); -}); diff --git a/yarn.lock b/yarn.lock index 986fcb0c09..2a561a603f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4586,6 +4586,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/types": "workspace:^" + express: ^4.18.2 google-auth-library: ^8.0.0 msw: ^1.0.0 supertest: ^6.1.3 @@ -25460,7 +25461,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1": +"express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1, express@npm:^4.18.2": version: 4.18.2 resolution: "express@npm:4.18.2" dependencies: From 7ab7ad5c79b9ca4d6a32ff32cb57f42f17e691c7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 12:48:29 +0200 Subject: [PATCH 48/66] auth-backend: refactor google provider tests Signed-off-by: Patrik Oldsberg --- .../src/providers/google/provider.test.ts | 109 ++++++++---------- 1 file changed, 46 insertions(+), 63 deletions(-) diff --git a/plugins/auth-backend/src/providers/google/provider.test.ts b/plugins/auth-backend/src/providers/google/provider.test.ts index 3c0b367be2..abde2b66d1 100644 --- a/plugins/auth-backend/src/providers/google/provider.test.ts +++ b/plugins/auth-backend/src/providers/google/provider.test.ts @@ -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), }); }); }); From 83941bb6179f91bb115b8923d385f5dcf95d4429 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 13:53:49 +0200 Subject: [PATCH 49/66] auth-node: add initial OAuth route handlers test Signed-off-by: Patrik Oldsberg --- plugins/auth-node/package.json | 3 + .../oauth/createOAuthRouteHandlers.test.ts | 156 ++++++++++++++++++ yarn.lock | 5 +- 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index b0c503c144..5e3ddd5a20 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -48,8 +48,11 @@ "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": [ diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts new file mode 100644 index 0000000000..46b6fe4b07 --- /dev/null +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts @@ -0,0 +1,156 @@ +/* + * 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'; + +const mockAuthenticator: OAuthAuthenticator = { + initialize: jest.fn(), + start: jest.fn(), + authenticate: jest.fn(), + refresh: jest.fn(), + logout: jest.fn(), + defaultProfileTransform: jest.fn(async () => ({ profile: {} })), +}; + +const baseConfig = { + authenticator: mockAuthenticator, + appUrl: 'http://localhost:3000', + baseUrl: 'http://localhost:7007', + isOriginAllowed: () => true, + providerId: 'my-provider', + config: new ConfigReader({}), + resolverContext: { ctx: 'ctx' } as unknown as AuthResolverContext, +}; + +function wrapInApp(handlers: AuthProviderRouteHandlers) { + const app = express(); + + const router = PromiseRouter(); + + router.use(cookieParser()); + app.use(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 getCookie(test: SuperAgentTest, name: string) { + return test.jar.getCookie(`my-provider-${name}`, { + domain: 'localhost', + path: '/my-provider', + script: false, + secure: false, + }); +} + +describe('createOAuthRouteHandlers', () => { + 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('/start'); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + error: { + name: 'InputError', + message: 'No env provided in request query parameters', + }, + }); + }); + }); + + describe('logout', () => { + it('should log out', async () => { + const agent = request.agent( + wrapInApp(createOAuthRouteHandlers(baseConfig)), + ); + + agent.jar.setCookie( + 'my-provider-refresh-token=my-refresh-token', + 'localhost', + '/my-provider', + ); + + expect(getCookie(agent, 'refresh-token').value).toBe('my-refresh-token'); + + const res = await agent + .post('/logout') + .set('X-Requested-With', 'XMLHttpRequest'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({}); + + expect(getCookie(agent, 'refresh-token')).toBeUndefined(); + }); + + it('should reject requests without CSRF header', async () => { + const app = wrapInApp(createOAuthRouteHandlers(baseConfig)); + + const res = await request(app).post('/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('/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', + }, + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 2a561a603f..2dd5f941c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4691,12 +4691,15 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/types": "workspace:^" "@types/express": "*" + cookie-parser: ^1.4.6 express: ^4.17.1 + express-promise-router: ^4.1.1 jose: ^4.6.0 lodash: ^4.17.21 msw: ^1.0.0 node-fetch: ^2.6.7 passport: ^0.6.0 + supertest: ^6.1.3 uuid: ^8.0.0 winston: ^3.2.1 zod: ^3.21.4 @@ -22029,7 +22032,7 @@ __metadata: languageName: node linkType: hard -"cookie-parser@npm:^1.4.5": +"cookie-parser@npm:^1.4.5, cookie-parser@npm:^1.4.6": version: 1.4.6 resolution: "cookie-parser@npm:1.4.6" dependencies: From 8d5aa7a3a3eec670757995185db07019533706d6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 14:30:39 +0200 Subject: [PATCH 50/66] auth-node: start tests for oauth handler Signed-off-by: Patrik Oldsberg --- .../oauth/createOAuthRouteHandlers.test.ts | 118 ++++++++++++++++-- 1 file changed, 110 insertions(+), 8 deletions(-) diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts index 46b6fe4b07..73da115af8 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts @@ -23,14 +23,15 @@ import { AuthProviderRouteHandlers, AuthResolverContext } from '../types'; import { createOAuthRouteHandlers } from './createOAuthRouteHandlers'; import { OAuthAuthenticator } from './types'; import { errorHandler } from '@backstage/backend-common'; +import { encodeOAuthState } from './state'; -const mockAuthenticator: OAuthAuthenticator = { - initialize: jest.fn(), +const mockAuthenticator: jest.Mocked> = { + initialize: jest.fn(_r => ({ ctx: 'authenticator' })), start: jest.fn(), authenticate: jest.fn(), refresh: jest.fn(), logout: jest.fn(), - defaultProfileTransform: jest.fn(async () => ({ profile: {} })), + defaultProfileTransform: jest.fn(async (_r, _c) => ({ profile: {} })), }; const baseConfig = { @@ -40,7 +41,7 @@ const baseConfig = { isOriginAllowed: () => true, providerId: 'my-provider', config: new ConfigReader({}), - resolverContext: { ctx: 'ctx' } as unknown as AuthResolverContext, + resolverContext: { ctx: 'resolver' } as unknown as AuthResolverContext, }; function wrapInApp(handlers: AuthProviderRouteHandlers) { @@ -66,8 +67,26 @@ function wrapInApp(handlers: AuthProviderRouteHandlers) { return app; } -function getCookie(test: SuperAgentTest, name: string) { - return test.jar.getCookie(`my-provider-${name}`, { +function getNonceCookie(test: SuperAgentTest) { + return test.jar.getCookie(`my-provider-nonce`, { + domain: 'localhost', + path: '/my-provider/handler', + script: false, + secure: false, + }); +} + +function getRefreshTokenCookie(test: SuperAgentTest) { + return test.jar.getCookie(`my-provider-refresh-token`, { + domain: 'localhost', + path: '/my-provider', + script: false, + secure: false, + }); +} + +function getGrantedScopesCookie(test: SuperAgentTest) { + return test.jar.getCookie(`my-provider-granted-scope`, { domain: 'localhost', path: '/my-provider', script: false, @@ -76,6 +95,8 @@ function getCookie(test: SuperAgentTest, name: string) { } describe('createOAuthRouteHandlers', () => { + afterEach(() => jest.clearAllMocks()); + it('should be created', () => { const handlers = createOAuthRouteHandlers(baseConfig); expect(handlers).toEqual({ @@ -99,6 +120,87 @@ describe('createOAuthRouteHandlers', () => { }, }); }); + + it('should start', async () => { + const agent = request.agent( + wrapInApp(createOAuthRouteHandlers(baseConfig)), + ); + + mockAuthenticator.start.mockResolvedValue({ + url: 'https://example.com/redirect', + }); + + const res = await agent.get('/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('/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('logout', () => { @@ -113,7 +215,7 @@ describe('createOAuthRouteHandlers', () => { '/my-provider', ); - expect(getCookie(agent, 'refresh-token').value).toBe('my-refresh-token'); + expect(getRefreshTokenCookie(agent).value).toBe('my-refresh-token'); const res = await agent .post('/logout') @@ -122,7 +224,7 @@ describe('createOAuthRouteHandlers', () => { expect(res.status).toBe(200); expect(res.body).toEqual({}); - expect(getCookie(agent, 'refresh-token')).toBeUndefined(); + expect(getRefreshTokenCookie(agent)).toBeUndefined(); }); it('should reject requests without CSRF header', async () => { From 2f8c1e75d41bef7f6f9bd0f0663ddec84c369b18 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 15:15:02 +0200 Subject: [PATCH 51/66] auth-node: OAuth handlers frame handler test + fixes Signed-off-by: Patrik Oldsberg --- .../oauth/createOAuthRouteHandlers.test.ts | 346 +++++++++++++++++- .../src/oauth/createOAuthRouteHandlers.ts | 7 +- plugins/auth-node/src/oauth/state.ts | 13 +- 3 files changed, 337 insertions(+), 29 deletions(-) diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts index 73da115af8..c0d9cfcb40 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts @@ -23,7 +23,9 @@ import { AuthProviderRouteHandlers, AuthResolverContext } from '../types'; import { createOAuthRouteHandlers } from './createOAuthRouteHandlers'; import { OAuthAuthenticator } from './types'; import { errorHandler } from '@backstage/backend-common'; -import { encodeOAuthState } from './state'; +import { encodeOAuthState, OAuthState } from './state'; +import { WebMessageResponse } from '../flow'; +import { PassportProfile } from '../passport'; const mockAuthenticator: jest.Mocked> = { initialize: jest.fn(_r => ({ ctx: 'authenticator' })), @@ -34,10 +36,23 @@ const mockAuthenticator: jest.Mocked> = { 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://localhost:3000', - baseUrl: 'http://localhost:7007', + appUrl: 'http://127.0.0.1', + baseUrl: 'http://127.0.0.1:7007', isOriginAllowed: () => true, providerId: 'my-provider', config: new ConfigReader({}), @@ -50,7 +65,7 @@ function wrapInApp(handlers: AuthProviderRouteHandlers) { const router = PromiseRouter(); router.use(cookieParser()); - app.use(router); + app.use('/my-provider', router); app.use(errorHandler()); router.get('/start', handlers.start.bind(handlers)); @@ -68,8 +83,8 @@ function wrapInApp(handlers: AuthProviderRouteHandlers) { } function getNonceCookie(test: SuperAgentTest) { - return test.jar.getCookie(`my-provider-nonce`, { - domain: 'localhost', + return test.jar.getCookie('my-provider-nonce', { + domain: '127.0.0.1', path: '/my-provider/handler', script: false, secure: false, @@ -77,8 +92,8 @@ function getNonceCookie(test: SuperAgentTest) { } function getRefreshTokenCookie(test: SuperAgentTest) { - return test.jar.getCookie(`my-provider-refresh-token`, { - domain: 'localhost', + return test.jar.getCookie('my-provider-refresh-token', { + domain: '127.0.0.1', path: '/my-provider', script: false, secure: false, @@ -86,14 +101,25 @@ function getRefreshTokenCookie(test: SuperAgentTest) { } function getGrantedScopesCookie(test: SuperAgentTest) { - return test.jar.getCookie(`my-provider-granted-scope`, { - domain: 'localhost', + return test.jar.getCookie('my-provider-granted-scope', { + domain: '127.0.0.1', path: '/my-provider', script: false, secure: false, }); } +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]), + }; +} + describe('createOAuthRouteHandlers', () => { afterEach(() => jest.clearAllMocks()); @@ -110,7 +136,7 @@ describe('createOAuthRouteHandlers', () => { describe('start', () => { it('should require an env query', async () => { const app = wrapInApp(createOAuthRouteHandlers(baseConfig)); - const res = await request(app).get('/start'); + const res = await request(app).get('/my-provider/start'); expect(res.status).toBe(400); expect(res.body).toMatchObject({ @@ -130,7 +156,9 @@ describe('createOAuthRouteHandlers', () => { url: 'https://example.com/redirect', }); - const res = await agent.get('/start?env=development&scope=my-scope'); + const res = await agent.get( + '/my-provider/start?env=development&scope=my-scope', + ); const { value: nonce } = getNonceCookie(agent); @@ -172,7 +200,7 @@ describe('createOAuthRouteHandlers', () => { url: 'https://example.com/redirect', }); - const res = await agent.get('/start').query({ + const res = await agent.get('/my-provider/start').query({ env: 'development', scope: 'my-scope', origin: 'https://remotehost', @@ -203,6 +231,290 @@ describe('createOAuthRouteHandlers', () => { }); }); + 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(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('logout', () => { it('should log out', async () => { const agent = request.agent( @@ -211,14 +523,14 @@ describe('createOAuthRouteHandlers', () => { agent.jar.setCookie( 'my-provider-refresh-token=my-refresh-token', - 'localhost', + '127.0.0.1', '/my-provider', ); expect(getRefreshTokenCookie(agent).value).toBe('my-refresh-token'); const res = await agent - .post('/logout') + .post('/my-provider/logout') .set('X-Requested-With', 'XMLHttpRequest'); expect(res.status).toBe(200); @@ -230,7 +542,7 @@ describe('createOAuthRouteHandlers', () => { it('should reject requests without CSRF header', async () => { const app = wrapInApp(createOAuthRouteHandlers(baseConfig)); - const res = await request(app).post('/logout'); + const res = await request(app).post('/my-provider/logout'); expect(res.status).toBe(401); expect(res.body).toMatchObject({ error: { @@ -244,7 +556,7 @@ describe('createOAuthRouteHandlers', () => { const app = wrapInApp(createOAuthRouteHandlers(baseConfig)); const res = await request(app) - .post('/logout') + .post('/my-provider/logout') .set('X-Requested-With', 'wrong-value'); expect(res.status).toBe(401); expect(res.body).toMatchObject({ diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts index 6c4819a320..011b3fc0f0 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts @@ -179,13 +179,10 @@ export function createOAuthRouteHandlers( const cookieNonce = cookieManager.getNonce(req); const stateNonce = state.nonce; if (!cookieNonce) { - throw new Error('Auth response is missing cookie nonce'); - } - if (stateNonce.length === 0) { - throw new Error('Auth response is missing state nonce'); + throw new NotAllowedError('Auth response is missing cookie nonce'); } if (cookieNonce !== stateNonce) { - throw new Error('Invalid nonce'); + throw new NotAllowedError('Invalid nonce'); } const result = await authenticator.authenticate( diff --git a/plugins/auth-node/src/oauth/state.ts b/plugins/auth-node/src/oauth/state.ts index 71c5a2afb8..c5b96414fa 100644 --- a/plugins/auth-node/src/oauth/state.ts +++ b/plugins/auth-node/src/oauth/state.ts @@ -16,6 +16,7 @@ import pickBy from 'lodash/pickBy'; import { Request } from 'express'; +import { NotAllowedError } from '@backstage/errors'; /** @public */ export type OAuthState = { @@ -49,13 +50,11 @@ export function decodeOAuthState(encodedState: string): OAuthState { const state = Object.fromEntries( new URLSearchParams(Buffer.from(encodedState, 'hex').toString('utf-8')), ); - if ( - !state.nonce || - !state.env || - state.nonce?.length === 0 || - state.env?.length === 0 - ) { - throw Error(`Invalid state passed via request`); + 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; From 0678d122a8ee6e2178fff7392d0f4de4253a1a82 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 15:28:41 +0200 Subject: [PATCH 52/66] auth-node: tests for OAuth handler refresh Signed-off-by: Patrik Oldsberg --- .../oauth/createOAuthRouteHandlers.test.ts | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts index c0d9cfcb40..926d08fa77 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts @@ -255,6 +255,11 @@ describe('createOAuthRouteHandlers', () => { } 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', @@ -515,6 +520,184 @@ describe('createOAuthRouteHandlers', () => { }); }); + 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( From 3db911fc9835c30b4c17d79675fbbf1afd02770e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 15:32:46 +0200 Subject: [PATCH 53/66] auth-backend: pass through global config values at top-level Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/src/service/router.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index b28d2aeca1..026a6263b7 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -130,6 +130,9 @@ export async function createRouter( try { const provider = providerFactory({ providerId, + appUrl, + baseUrl: authUrl, + isOriginAllowed, globalConfig: { baseUrl: authUrl, appUrl, From 258b410cad9825e3e87da1fe8f1c9574b82a2f98 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 15:36:18 +0200 Subject: [PATCH 54/66] auth refactor type fixes Signed-off-by: Patrik Oldsberg --- .../auth-backend-module-gcp-iap-provider/src/module.ts | 1 + plugins/auth-backend-module-google-provider/src/module.ts | 1 + plugins/auth-backend/src/index.ts | 3 --- .../src/lib/legacy/adaptLegacyOAuthHandler.ts | 2 +- .../src/lib/legacy/adaptLegacyOAuthSignInResolver.ts | 2 +- .../src/lib/legacy/adaptOAuthSignInResolverToLegacy.ts | 1 + plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts | 2 +- .../auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts | 2 +- plugins/auth-backend/src/lib/oauth/types.ts | 2 +- plugins/auth-backend/src/providers/gcp-iap/types.ts | 2 +- .../auth-backend/src/providers/microsoft/provider.test.ts | 3 +++ plugins/auth-node/src/identity/index.ts | 2 +- plugins/auth-node/src/oauth/index.ts | 1 - plugins/auth-node/src/oauth/types.ts | 8 ++++++++ .../auth-node/src/proxy/createProxyAuthProviderFactory.ts | 1 + plugins/auth-node/src/proxy/types.ts | 2 ++ .../auth-node/src/sign-in/createSignInResolverFactory.ts | 3 +++ 17 files changed, 27 insertions(+), 11 deletions(-) diff --git a/plugins/auth-backend-module-gcp-iap-provider/src/module.ts b/plugins/auth-backend-module-gcp-iap-provider/src/module.ts index 38742e4236..90771eecb9 100644 --- a/plugins/auth-backend-module-gcp-iap-provider/src/module.ts +++ b/plugins/auth-backend-module-gcp-iap-provider/src/module.ts @@ -23,6 +23,7 @@ import { import { gcpIapAuthenticator } from './authenticator'; import { gcpIapSignInResolvers } from './resolvers'; +/** @public */ export const authModuleGcpIapProvider = createBackendModule({ pluginId: 'auth', moduleId: 'gcpIapProvider', diff --git a/plugins/auth-backend-module-google-provider/src/module.ts b/plugins/auth-backend-module-google-provider/src/module.ts index fe067c223a..0354f99880 100644 --- a/plugins/auth-backend-module-google-provider/src/module.ts +++ b/plugins/auth-backend-module-google-provider/src/module.ts @@ -23,6 +23,7 @@ import { import { googleAuthenticator } from './authenticator'; import { googleSignInResolvers } from './resolvers'; +/** @public */ export const authModuleGoogleProvider = createBackendModule({ pluginId: 'auth', moduleId: 'googleProvider', diff --git a/plugins/auth-backend/src/index.ts b/plugins/auth-backend/src/index.ts index cc6d460f3c..f8a8580359 100644 --- a/plugins/auth-backend/src/index.ts +++ b/plugins/auth-backend/src/index.ts @@ -31,9 +31,6 @@ export * from './lib/flow'; // OAuth wrapper over a passport or a custom `strategy`. export * from './lib/oauth'; -// Helpers to convert new API in @backstage/plugin-auth-node to old API -export * from './lib/legacy'; - export * from './lib/catalog'; export { getDefaultOwnershipEntityRefs } from './lib/resolvers'; diff --git a/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthHandler.ts b/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthHandler.ts index 3114306424..37f10ff184 100644 --- a/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthHandler.ts +++ b/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthHandler.ts @@ -22,7 +22,7 @@ import { AuthHandler } from '../../providers'; import { OAuthResult } from '../oauth'; import { PassportProfile } from '../passport/types'; -/** @public */ +/** @internal */ export function adaptLegacyOAuthHandler( authHandler?: AuthHandler, ): ProfileTransform> | undefined { diff --git a/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthSignInResolver.ts b/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthSignInResolver.ts index 02056c0517..e0318464ed 100644 --- a/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthSignInResolver.ts +++ b/plugins/auth-backend/src/lib/legacy/adaptLegacyOAuthSignInResolver.ts @@ -21,7 +21,7 @@ import { } from '@backstage/plugin-auth-node'; import { OAuthResult } from '../oauth'; -/** @public */ +/** @internal */ export function adaptLegacyOAuthSignInResolver( signInResolver?: SignInResolver, ): SignInResolver> | undefined { diff --git a/plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.ts b/plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.ts index 43b8fa2e9a..8be6049348 100644 --- a/plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.ts +++ b/plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.ts @@ -21,6 +21,7 @@ import { } from '@backstage/plugin-auth-node'; import { OAuthResult } from '../oauth'; +/** @internal */ export function adaptOAuthSignInResolverToLegacy< TKeys extends string, >(resolvers: { diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index 0e0fb2b90f..9b4afb4b2b 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -52,7 +52,7 @@ export const TEN_MINUTES_MS = 600 * 1000; /** * @public - * @deprecated + * @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead */ export type OAuthAdapterOptions = { providerId: string; diff --git a/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts b/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts index eef85f4e34..c9244eb29e 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts @@ -20,4 +20,4 @@ import { OAuthEnvironmentHandler as _OAuthEnvironmentHandler } from '@backstage/ * @public * @deprecated import from `@backstage/plugin-auth-node` instead */ -export type OAuthEnvironmentHandler = _OAuthEnvironmentHandler; +export const OAuthEnvironmentHandler = _OAuthEnvironmentHandler; diff --git a/plugins/auth-backend/src/lib/oauth/types.ts b/plugins/auth-backend/src/lib/oauth/types.ts index e91f3819c4..d133341ef4 100644 --- a/plugins/auth-backend/src/lib/oauth/types.ts +++ b/plugins/auth-backend/src/lib/oauth/types.ts @@ -26,7 +26,7 @@ import { OAuthStartResponse, ProfileInfo } from '../../providers/types'; * Common options for passport.js-based OAuth providers * * @public - * @deprecated + * @deprecated No longer in use */ export type OAuthProviderOptions = { /** diff --git a/plugins/auth-backend/src/providers/gcp-iap/types.ts b/plugins/auth-backend/src/providers/gcp-iap/types.ts index 0a88979323..b1f69318fc 100644 --- a/plugins/auth-backend/src/providers/gcp-iap/types.ts +++ b/plugins/auth-backend/src/providers/gcp-iap/types.ts @@ -32,6 +32,6 @@ export type GcpIapTokenInfo = _GcpIapTokenInfo; * callbacks. * * @public - * @deprecated + * @deprecated import from `@backstage/plugin-auth-backend-module-gcp-iap-provider` instead */ export type GcpIapResult = _GcpIapResult; diff --git a/plugins/auth-backend/src/providers/microsoft/provider.test.ts b/plugins/auth-backend/src/providers/microsoft/provider.test.ts index 6f8df2d4df..a1128ee6b9 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.test.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.test.ts @@ -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', diff --git a/plugins/auth-node/src/identity/index.ts b/plugins/auth-node/src/identity/index.ts index 1a39c7aba3..24e2dcb691 100644 --- a/plugins/auth-node/src/identity/index.ts +++ b/plugins/auth-node/src/identity/index.ts @@ -21,4 +21,4 @@ export { type IdentityClientOptions, } from './DefaultIdentityClient'; export { IdentityClient } from './IdentityClient'; -export type { IdentityApi } from './IdentityApi'; +export type { IdentityApi, IdentityApiGetIdentityRequest } from './IdentityApi'; diff --git a/plugins/auth-node/src/oauth/index.ts b/plugins/auth-node/src/oauth/index.ts index ff07a1e6a8..25213dd2f0 100644 --- a/plugins/auth-node/src/oauth/index.ts +++ b/plugins/auth-node/src/oauth/index.ts @@ -40,6 +40,5 @@ export { type OAuthAuthenticatorRefreshInput, type OAuthAuthenticatorResult, type OAuthAuthenticatorStartInput, - type OAuthProfileTransform, type OAuthSession, } from './types'; diff --git a/plugins/auth-node/src/oauth/types.ts b/plugins/auth-node/src/oauth/types.ts index e6ddf370b6..c552de394e 100644 --- a/plugins/auth-node/src/oauth/types.ts +++ b/plugins/auth-node/src/oauth/types.ts @@ -18,6 +18,7 @@ import { Config } from '@backstage/config'; import { Request } from 'express'; import { ProfileTransform } from '../types'; +/** @public */ export interface OAuthSession { accessToken: string; tokenType: string; @@ -27,33 +28,39 @@ export interface OAuthSession { 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 { fullProfile: TProfile; session: OAuthSession; } +/** @public */ export interface OAuthAuthenticator { defaultProfileTransform: ProfileTransform>; shouldPersistScopes?: boolean; @@ -73,6 +80,7 @@ export interface OAuthAuthenticator { logout?(input: OAuthAuthenticatorLogoutInput, ctx: TContext): Promise; } +/** @public */ export function createOAuthAuthenticator( authenticator: OAuthAuthenticator, ): OAuthAuthenticator { diff --git a/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts b/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts index a50225e911..8626c40b33 100644 --- a/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts +++ b/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts @@ -26,6 +26,7 @@ import { import { createProxyAuthRouteHandlers } from './createProxyRouteHandlers'; import { ProxyAuthenticator } from './types'; +/** @public */ export function createProxyAuthProviderFactory(options: { authenticator: ProxyAuthenticator; profileTransform?: ProfileTransform; diff --git a/plugins/auth-node/src/proxy/types.ts b/plugins/auth-node/src/proxy/types.ts index a7e0da2d4c..969b022abb 100644 --- a/plugins/auth-node/src/proxy/types.ts +++ b/plugins/auth-node/src/proxy/types.ts @@ -18,6 +18,7 @@ import { Config } from '@backstage/config'; import { Request } from 'express'; import { ProfileTransform } from '../types'; +/** @public */ export interface ProxyAuthenticator { defaultProfileTransform: ProfileTransform; initialize(ctx: { config: Config }): Promise; @@ -27,6 +28,7 @@ export interface ProxyAuthenticator { ): Promise<{ result: TResult }>; } +/** @public */ export function createProxyAuthenticator( authenticator: ProxyAuthenticator, ): ProxyAuthenticator { diff --git a/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts b/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts index 25e65754fe..6379eafd25 100644 --- a/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts +++ b/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts @@ -19,6 +19,7 @@ import { SignInResolver } from '../types'; import zodToJsonSchema from 'zod-to-json-schema'; import { JsonObject } from '@backstage/types'; +/** @public */ export interface SignInResolverFactory { ( ...options: undefined extends TOptions @@ -28,6 +29,7 @@ export interface SignInResolverFactory { optionsJsonSchema?: JsonObject; } +/** @public */ export interface SignInResolverFactoryOptions< TAuthResult, TOptionsOutput, @@ -37,6 +39,7 @@ export interface SignInResolverFactoryOptions< create(options: TOptionsOutput): SignInResolver; } +/** @public */ export function createSignInResolverFactory< TAuthResult, TOptionsOutput, From 9ae287521a800cacb9a2fb042ca963cd8ae6dbf6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 17:13:03 +0200 Subject: [PATCH 55/66] auth-backend: fix legacy adaopter test Signed-off-by: Patrik Oldsberg --- .../src/lib/legacy/adaptOAuthSignInResolverToLegacy.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.test.ts b/plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.test.ts index 37853d4840..521dcf6395 100644 --- a/plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.test.ts +++ b/plugins/auth-backend/src/lib/legacy/adaptOAuthSignInResolverToLegacy.test.ts @@ -76,7 +76,10 @@ describe('adaptOAuthSignInResolverToLegacy', () => { ); expect(resolverB).not.toHaveBeenCalled(); - legacyResolvers.resolverB()({} as any, {} as any); + legacyResolvers.resolverB()( + { profile: {}, result: { params: {} } } as any, + {} as any, + ); expect(resolverB).toHaveBeenCalled(); }); }); From e0cc11461125250e0022984c183145129e57baa8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 17:13:22 +0200 Subject: [PATCH 56/66] auth-*: update API reports Signed-off-by: Patrik Oldsberg --- .../api-report.md | 46 ++ .../api-report.md | 31 + plugins/auth-backend/api-report.md | 283 +++------ plugins/auth-node/api-report.md | 575 ++++++++++++++++++ 4 files changed, 748 insertions(+), 187 deletions(-) create mode 100644 plugins/auth-backend-module-gcp-iap-provider/api-report.md create mode 100644 plugins/auth-backend-module-google-provider/api-report.md diff --git a/plugins/auth-backend-module-gcp-iap-provider/api-report.md b/plugins/auth-backend-module-gcp-iap-provider/api-report.md new file mode 100644 index 0000000000..02b77cea36 --- /dev/null +++ b/plugins/auth-backend-module-gcp-iap-provider/api-report.md @@ -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; + }, + { + 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) +``` diff --git a/plugins/auth-backend-module-google-provider/api-report.md b/plugins/auth-backend-module-google-provider/api-report.md new file mode 100644 index 0000000000..8040c44832 --- /dev/null +++ b/plugins/auth-backend-module-google-provider/api-report.md @@ -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, + unknown + >; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 80db2e0b71..188cca75e0 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -5,24 +5,40 @@ ```ts /// -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 export type AuthHandler = ( @@ -35,68 +51,23 @@ 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; - logout?(req: express.Request, res: express.Response): Promise; - refresh?(req: express.Request, res: express.Response): Promise; - start(req: express.Request, res: express.Response): Promise; -} +// @public @deprecated (undocumented) +export type AuthProviderRouteHandlers = AuthProviderRouteHandlers_2; -// @public -export type AuthResolverCatalogUserQuery = - | { - entityRef: - | string - | { - kind?: string; - namespace?: string; - name: string; - }; - } - | { - annotations: Record; - } - | { - filter: Exclude; - }; +// @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; -}; +// @public @deprecated (undocumented) +export type AuthResolverContext = AuthResolverContext_2; -// @public (undocumented) -export type AuthResponse = { - providerInfo: ProviderInfo; - profile: ProfileInfo; - backstageIdentity?: BackstageIdentityResponse; -}; +// @public @deprecated (undocumented) +export type AuthResponse = ClientAuthResponse; // @public (undocumented) export type AwsAlbResult = { @@ -151,7 +122,7 @@ export class CatalogIdentityClient { findUser(query: { annotations: Record }): Promise; resolveCatalogMembership(query: { entityRefs: string[]; - logger?: Logger; + logger?: LoggerService; }): Promise; } @@ -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 = { 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; } -// @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); - // (undocumented) - frameHandler(req: express.Request, res: express.Response): Promise; - // (undocumented) - logout(req: express.Request, res: express.Response): Promise; - // (undocumented) - static mapConfig( - config: Config, - factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers, - ): OAuthEnvironmentHandler; - // (undocumented) - refresh(req: express.Request, res: express.Response): Promise; - // (undocumented) - start(req: express.Request, res: express.Response): Promise; -} +// @public @deprecated (undocumented) +export const OAuthEnvironmentHandler: typeof OAuthEnvironmentHandler_2; -// @public +// @public @deprecated (undocumented) export interface OAuthHandlers { handler(req: express.Request): Promise<{ response: OAuthResponse; @@ -341,7 +281,7 @@ export interface OAuthHandlers { start(req: OAuthStartRequest): Promise; } -// @public (undocumented) +// @public @deprecated (undocumented) export type OAuthLogoutRequest = express.Request<{}> & { refreshToken: string; }; @@ -354,39 +294,40 @@ 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; @@ -398,15 +339,8 @@ export type OAuthStartResponse = { 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; userIdMatchingUserEntityAnnotation(): SignInResolver; @@ -513,7 +441,7 @@ export const providers: Readonly<{ | undefined; } | undefined, - ) => AuthProviderFactory; + ) => AuthProviderFactory_2; resolvers: Readonly<{ emailMatchingUserEntityProfileEmail: () => SignInResolver; }>; @@ -525,18 +453,18 @@ export const providers: Readonly<{ resolver: SignInResolver; }; cache?: CacheService | undefined; - }) => AuthProviderFactory; + }) => AuthProviderFactory_2; resolvers: Readonly<{ emailMatchingUserEntityProfileEmail: () => SignInResolver; }>; }>; gcpIap: Readonly<{ create: (options: { - authHandler?: AuthHandler | undefined; + authHandler?: AuthHandler | undefined; signIn: { - resolver: SignInResolver; + resolver: SignInResolver; }; - }) => 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; }>; @@ -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; - emailMatchingUserEntityProfileEmail: () => SignInResolver; - emailMatchingUserEntityAnnotation(): SignInResolver; + emailMatchingUserEntityProfileEmail: () => SignInResolver_2; + emailLocalPartMatchingUserEntityName: () => SignInResolver_2; + emailMatchingUserEntityAnnotation: () => SignInResolver_2; }>; }>; microsoft: Readonly<{ @@ -603,7 +531,7 @@ export const providers: Readonly<{ | undefined; } | undefined, - ) => AuthProviderFactory; + ) => AuthProviderFactory_2; resolvers: Readonly<{ emailLocalPartMatchingUserEntityName: () => SignInResolver; emailMatchingUserEntityProfileEmail: () => SignInResolver; @@ -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>; }; - }) => AuthProviderFactory; + }) => AuthProviderFactory_2; resolvers: never; }>; oidc: Readonly<{ @@ -646,7 +574,7 @@ export const providers: Readonly<{ | undefined; } | undefined, - ) => AuthProviderFactory; + ) => AuthProviderFactory_2; resolvers: Readonly<{ emailLocalPartMatchingUserEntityName: () => SignInResolver; emailMatchingUserEntityProfileEmail: () => SignInResolver; @@ -664,7 +592,7 @@ export const providers: Readonly<{ | undefined; } | undefined, - ) => AuthProviderFactory; + ) => AuthProviderFactory_2; resolvers: Readonly<{ emailLocalPartMatchingUserEntityName: () => SignInResolver; emailMatchingUserEntityProfileEmail: () => SignInResolver; @@ -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; }>; @@ -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 = { - profile: ProfileInfo; - result: TAuthResult; -}; +// @public @deprecated (undocumented) +export type SignInInfo = SignInInfo_2; -// @public -export type SignInResolver = ( - info: SignInInfo, - context: AuthResolverContext, -) => Promise; +// @public @deprecated (undocumented) +export type SignInResolver = SignInResolver_2; // @public (undocumented) export type StateEncoder = (req: OAuthStartRequest) => Promise<{ encodedState: string; }>; -// @public -export type TokenParams = { - claims: { - sub: string; - ent?: string[]; - } & Record; -}; +// @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; - } - | { - type: 'authorization_response'; - error: Error; - }; +// @public @deprecated (undocumented) +export type WebMessageResponse = WebMessageResponse_2; ``` diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index bc9e392929..4a384834d6 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -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; + logout?(req: Request_2, res: Response_2): Promise; + refresh?(req: Request_2, res: Response_2): Promise; + start(req: Request_2, res: Response_2): Promise; +} + +// @public (undocumented) +export interface AuthProvidersExtensionPoint { + // (undocumented) + registerProvider(options: AuthProviderRegistrationOptions): void; +} + +// @public (undocumented) +export const authProvidersExtensionPoint: ExtensionPoint; + +// @public +export type AuthResolverCatalogUserQuery = + | { + entityRef: + | string + | { + kind?: string; + namespace?: string; + name: string; + }; + } + | { + annotations: Record; + } + | { + filter: EntityFilterQuery; + }; + +// @public +export type AuthResolverContext = { + issueToken(params: TokenParams): Promise<{ + token: string; + }>; + findCatalogUser(query: AuthResolverCatalogUserQuery): Promise<{ + entity: Entity; + }>; + signInWithCatalogUser( + query: AuthResolverCatalogUserQuery, + ): Promise; +}; // @public export interface BackstageIdentityResponse extends BackstageSignInResult { @@ -23,6 +115,99 @@ export type BackstageUserIdentity = { ownershipEntityRefs: string[]; }; +// @public (undocumented) +export type ClientAuthResponse = { + 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( + authenticator: OAuthAuthenticator, +): OAuthAuthenticator; + +// @public (undocumented) +export function createOAuthProviderFactory(options: { + authenticator: OAuthAuthenticator; + stateTransform?: OAuthStateTransform; + profileTransform?: ProfileTransform>; + signInResolver?: SignInResolver>; + signInResolverFactories?: { + [name in string]: SignInResolverFactory< + OAuthAuthenticatorResult, + unknown + >; + }; +}): AuthProviderFactory; + +// @public (undocumented) +export function createOAuthRouteHandlers( + options: OAuthRouteHandlersOptions, +): AuthProviderRouteHandlers; + +// @public (undocumented) +export function createProxyAuthenticator( + authenticator: ProxyAuthenticator, +): ProxyAuthenticator; + +// @public (undocumented) +export function createProxyAuthProviderFactory(options: { + authenticator: ProxyAuthenticator; + profileTransform?: ProfileTransform; + signInResolver?: SignInResolver; + signInResolverFactories?: Record< + string, + SignInResolverFactory + >; +}): AuthProviderFactory; + +// @public (undocumented) +export function createProxyAuthRouteHandlers( + options: ProxyAuthRouteHandlersOptions, +): AuthProviderRouteHandlers; + +// @public (undocumented) +export function createSignInResolverFactory< + TAuthResult, + TOptionsOutput, + TOptionsInput, +>( + options: SignInResolverFactoryOptions< + TAuthResult, + TOptionsOutput, + TOptionsInput + >, +): SignInResolverFactory; + +// @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; } +// @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 { + // (undocumented) + authenticate( + input: OAuthAuthenticatorAuthenticateInput, + ctx: TContext, + ): Promise>; + // (undocumented) + defaultProfileTransform: ProfileTransform>; + // (undocumented) + initialize(ctx: { callbackUrl: string; config: Config }): TContext; + // (undocumented) + logout?(input: OAuthAuthenticatorLogoutInput, ctx: TContext): Promise; + // (undocumented) + refresh( + input: OAuthAuthenticatorRefreshInput, + ctx: TContext, + ): Promise>; + // (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 { + // (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); + // (undocumented) + frameHandler(req: express.Request, res: express.Response): Promise; + // (undocumented) + logout(req: express.Request, res: express.Response): Promise; + // (undocumented) + static mapConfig( + config: Config, + factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers, + ): OAuthEnvironmentHandler; + // (undocumented) + refresh(req: express.Request, res: express.Response): Promise; + // (undocumented) + start(req: express.Request, res: express.Response): Promise; +} + +// @public (undocumented) +export interface OAuthRouteHandlersOptions { + // (undocumented) + appUrl: string; + // (undocumented) + authenticator: OAuthAuthenticator; + // (undocumented) + baseUrl: string; + // (undocumented) + config: Config; + // (undocumented) + cookieConfigurer?: CookieConfigurer; + // (undocumented) + isOriginAllowed: (origin: string) => boolean; + // (undocumented) + profileTransform?: ProfileTransform>; + // (undocumented) + providerId: string; + // (undocumented) + resolverContext: AuthResolverContext; + // (undocumented) + signInResolver?: SignInResolver>; + // (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 (undocumented) +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 = ( + err?: Error, + result?: TResult, + privateInfo?: TPrivateInfo, +) => void; + +// @public (undocumented) +export class PassportHelpers { + // (undocumented) + static executeFetchUserProfileStrategy( + providerStrategy: Strategy, + accessToken: string, + ): Promise; + // (undocumented) + static executeFrameHandlerStrategy( + req: Request_2, + providerStrategy: Strategy, + options?: Record, + ): Promise<{ + result: TResult; + privateInfo: TPrivateInfo; + }>; + // (undocumented) + static executeRedirectStrategy( + req: Request_2, + providerStrategy: Strategy, + options: Record, + ): 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>; + // (undocumented) + static defaultProfileTransform: ProfileTransform< + OAuthAuthenticatorResult + >; + // (undocumented) + fetchProfile(accessToken: string): Promise; + // (undocumented) + static from(strategy: Strategy): PassportOAuthAuthenticatorHelper; + // (undocumented) + refresh( + input: OAuthAuthenticatorRefreshInput, + ): Promise>; + // (undocumented) + start( + input: OAuthAuthenticatorStartInput, + options: Record, + ): 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 = ( + result: TResult, + context: AuthResolverContext, +) => Promise<{ + profile: ProfileInfo; +}>; + +// @public (undocumented) +export interface ProxyAuthenticator { + // (undocumented) + authenticate( + options: { + req: Request_2; + }, + ctx: TContext, + ): Promise<{ + result: TResult; + }>; + // (undocumented) + defaultProfileTransform: ProfileTransform; + // (undocumented) + initialize(ctx: { config: Config }): Promise; +} + +// @public (undocumented) +export interface ProxyAuthRouteHandlersOptions { + // (undocumented) + authenticator: ProxyAuthenticator; + // (undocumented) + config: Config; + // (undocumented) + profileTransform?: ProfileTransform; + // (undocumented) + resolverContext: AuthResolverContext; + // (undocumented) + signInResolver: SignInResolver; +} + +// @public (undocumented) +export function readDeclarativeSignInResolver( + options: ReadDeclarativeSignInResolverOptions, +): SignInResolver | undefined; + +// @public (undocumented) +export interface ReadDeclarativeSignInResolverOptions { + // (undocumented) + config: Config; + // (undocumented) + signInResolverFactories: { + [name in string]: SignInResolverFactory; + }; +} + +// @public (undocumented) +export function sendWebMessageResponse( + res: Response_2, + appOrigin: string, + response: WebMessageResponse, +): void; + +// @public +export type SignInInfo = { + profile: ProfileInfo; + result: TAuthResult; +}; + +// @public +export type SignInResolver = ( + info: SignInInfo, + context: AuthResolverContext, +) => Promise; + +// @public (undocumented) +export interface SignInResolverFactory { + // (undocumented) + ( + ...options: undefined extends TOptions + ? [options?: TOptions] + : [options: TOptions] + ): SignInResolver; + // (undocumented) + optionsJsonSchema?: JsonObject; +} + +// @public (undocumented) +export interface SignInResolverFactoryOptions< + TAuthResult, + TOptionsOutput, + TOptionsInput, +> { + // (undocumented) + create(options: TOptionsOutput): SignInResolver; + // (undocumented) + optionsSchema?: ZodSchema; +} + +// @public +export type TokenParams = { + claims: { + sub: string; + ent?: string[]; + } & Record; +}; + +// @public +export type WebMessageResponse = + | { + type: 'authorization_response'; + response: ClientAuthResponse; + } + | { + type: 'authorization_response'; + error: Error; + }; ``` From 961179c5333e969c38237ed99ae73a27e7e12cf1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 17:32:52 +0200 Subject: [PATCH 57/66] auth-backend: deprecate more types that have been indirectly moved to auth-node Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/api-report.md | 14 +++++++------- plugins/auth-backend/src/lib/oauth/types.ts | 5 ++++- plugins/auth-backend/src/providers/types.ts | 12 ++++++++++-- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 188cca75e0..afd7f95c97 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -11,7 +11,7 @@ import { AuthProviderRouteHandlers as AuthProviderRouteHandlers_2 } from '@backs 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 { CacheClient } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; import { ClientAuthResponse } from '@backstage/plugin-auth-node'; import { Config } from '@backstage/config'; @@ -40,13 +40,13 @@ 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 = ( input: TAuthResult, context: AuthResolverContext, ) => Promise; -// @public +// @public @deprecated export type AuthHandlerResult = { profile: ProfileInfo; }; @@ -286,7 +286,7 @@ export type OAuthLogoutRequest = express.Request<{}> & { refreshToken: string; }; -// @public (undocumented) +// @public @deprecated (undocumented) export type OAuthProviderInfo = { accessToken: string; idToken?: string; @@ -333,7 +333,7 @@ export type OAuthStartRequest = express.Request<{}> & { state: OAuthState; }; -// @public (undocumented) +// @public @deprecated (undocumented) export type OAuthStartResponse = { url: string; status?: number; @@ -452,7 +452,7 @@ export const providers: Readonly<{ signIn: { resolver: SignInResolver; }; - cache?: CacheService | undefined; + cache?: CacheClient | undefined; }) => AuthProviderFactory_2; resolvers: Readonly<{ emailMatchingUserEntityProfileEmail: () => SignInResolver; @@ -680,7 +680,7 @@ export type SignInInfo = SignInInfo_2; // @public @deprecated (undocumented) export type SignInResolver = SignInResolver_2; -// @public (undocumented) +// @public @deprecated (undocumented) export type StateEncoder = (req: OAuthStartRequest) => Promise<{ encodedState: string; }>; diff --git a/plugins/auth-backend/src/lib/oauth/types.ts b/plugins/auth-backend/src/lib/oauth/types.ts index d133341ef4..b7205c9b85 100644 --- a/plugins/auth-backend/src/lib/oauth/types.ts +++ b/plugins/auth-backend/src/lib/oauth/types.ts @@ -69,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. diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 45d5be7c66..354387153c 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -46,7 +46,10 @@ export type AuthResolverContext = _AuthResolverContext; */ export type CookieConfigurer = _CookieConfigurer; -/** @public */ +/** + * @public + * @deprecated Use `createOAuthAuthenticator` from `@backstage/plugin-auth-node` instead + */ export type OAuthStartResponse = { /** * URL to redirect to @@ -105,6 +108,7 @@ export type SignInResolver = _SignInResolver; * information. * * @public + * @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead */ export type AuthHandlerResult = { profile: ProfileInfo }; @@ -120,13 +124,17 @@ export type AuthHandlerResult = { profile: ProfileInfo }; * group of users. * * @public + * @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead */ export type AuthHandler = ( input: TAuthResult, context: AuthResolverContext, ) => Promise; -/** @public */ +/** + * @public + * @deprecated Use `createOAuthRouteHandlers` from `@backstage/plugin-auth-node` instead + */ export type StateEncoder = ( req: OAuthStartRequest, ) => Promise<{ encodedState: string }>; From 8513cd7d00e3995344cdbb20acc97c5f2b036dcb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Aug 2023 17:52:39 +0200 Subject: [PATCH 58/66] changesets: added changesets for auth-backend refactor Signed-off-by: Patrik Oldsberg --- .changeset/dry-wasps-occur.md | 5 +++++ .changeset/healthy-tools-count.md | 5 +++++ .changeset/pink-cups-rescue.md | 28 ++++++++++++++++++++++++++++ .changeset/sweet-hairs-complain.md | 5 +++++ 4 files changed, 43 insertions(+) create mode 100644 .changeset/dry-wasps-occur.md create mode 100644 .changeset/healthy-tools-count.md create mode 100644 .changeset/pink-cups-rescue.md create mode 100644 .changeset/sweet-hairs-complain.md diff --git a/.changeset/dry-wasps-occur.md b/.changeset/dry-wasps-occur.md new file mode 100644 index 0000000000..0c7d231708 --- /dev/null +++ b/.changeset/dry-wasps-occur.md @@ -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. diff --git a/.changeset/healthy-tools-count.md b/.changeset/healthy-tools-count.md new file mode 100644 index 0000000000..5d3f04159f --- /dev/null +++ b/.changeset/healthy-tools-count.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Deprecated several exports that are now available from `@backstage/plugin-auth-node` instead. diff --git a/.changeset/pink-cups-rescue.md b/.changeset/pink-cups-rescue.md new file mode 100644 index 0000000000..abb69e88b7 --- /dev/null +++ b/.changeset/pink-cups-rescue.md @@ -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`. diff --git a/.changeset/sweet-hairs-complain.md b/.changeset/sweet-hairs-complain.md new file mode 100644 index 0000000000..56dede5617 --- /dev/null +++ b/.changeset/sweet-hairs-complain.md @@ -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. From 0f0e2a378b361bd88f5a3e4e1e7ccbb6baaf65ec Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 9 Aug 2023 00:25:17 +0200 Subject: [PATCH 59/66] auth-backend: fix oauth state test Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/src/lib/oauth/helpers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/auth-backend/src/lib/oauth/helpers.test.ts b/plugins/auth-backend/src/lib/oauth/helpers.test.ts index 58a3dcab05..c8db34e2cc 100644 --- a/plugins/auth-backend/src/lib/oauth/helpers.test.ts +++ b/plugins/auth-backend/src/lib/oauth/helpers.test.ts @@ -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', () => { From f60d50a59f85f9b34818740d408c68d3746fd1a1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 9 Aug 2023 10:41:53 +0200 Subject: [PATCH 60/66] auth-backend: sync API report Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/api-report.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index afd7f95c97..5eb038c436 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -11,7 +11,7 @@ import { AuthProviderRouteHandlers as AuthProviderRouteHandlers_2 } from '@backs 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 { CacheClient } from '@backstage/backend-common'; +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'; @@ -452,7 +452,7 @@ export const providers: Readonly<{ signIn: { resolver: SignInResolver; }; - cache?: CacheClient | undefined; + cache?: CacheService | undefined; }) => AuthProviderFactory_2; resolvers: Readonly<{ emailMatchingUserEntityProfileEmail: () => SignInResolver; From ac8d9dc29680caac0de2098a91f83886f632ee92 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 11 Aug 2023 16:02:06 +0200 Subject: [PATCH 61/66] auth-node: add missing @types/passport dep Signed-off-by: Patrik Oldsberg --- plugins/auth-node/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index 5e3ddd5a20..07573955cb 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -36,6 +36,7 @@ "@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", diff --git a/yarn.lock b/yarn.lock index 2dd5f941c2..ea50e46bc1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4691,6 +4691,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/types": "workspace:^" "@types/express": "*" + "@types/passport": ^1.0.3 cookie-parser: ^1.4.6 express: ^4.17.1 express-promise-router: ^4.1.1 From 296c818ddf1206b0f1565d97159bf146b42e3a73 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 11 Aug 2023 16:19:13 +0200 Subject: [PATCH 62/66] auth-node: move parseWebPessageResponse to test util + fix error value handling Signed-off-by: Patrik Oldsberg --- .../__testUtils__/parseWebMessageResponse.ts | 28 ++++++++++++++++ .../src/flow/sendWebMessageResponse.test.ts | 33 ++++++++++++++----- .../src/flow/sendWebMessageResponse.ts | 8 ++++- .../oauth/createOAuthRouteHandlers.test.ts | 13 +------- 4 files changed, 60 insertions(+), 22 deletions(-) create mode 100644 plugins/auth-node/src/flow/__testUtils__/parseWebMessageResponse.ts diff --git a/plugins/auth-node/src/flow/__testUtils__/parseWebMessageResponse.ts b/plugins/auth-node/src/flow/__testUtils__/parseWebMessageResponse.ts new file mode 100644 index 0000000000..eda8469ac6 --- /dev/null +++ b/plugins/auth-node/src/flow/__testUtils__/parseWebMessageResponse.ts @@ -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]), + }; +} diff --git a/plugins/auth-node/src/flow/sendWebMessageResponse.test.ts b/plugins/auth-node/src/flow/sendWebMessageResponse.test.ts index fd706159ba..cc192638f2 100644 --- a/plugins/auth-node/src/flow/sendWebMessageResponse.test.ts +++ b/plugins/auth-node/src/flow/sendWebMessageResponse.test.ts @@ -20,6 +20,7 @@ import { sendWebMessageResponse, type WebMessageResponse, } from './sendWebMessageResponse'; +import { parseWebMessageResponse } from './__testUtils__/parseWebMessageResponse'; describe('oauth helpers', () => { describe('safelyEncodeURIComponent', () => { @@ -78,7 +79,12 @@ describe('oauth helpers', () => { type: 'authorization_response', error: new Error('Unknown error occurred'), }; - const encoded = safelyEncodeURIComponent(JSON.stringify(data)); + const encoded = safelyEncodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + error: { name: 'Error', message: 'Unknown error occurred' }, + }), + ); sendWebMessageResponse(mockResponse, appOrigin, data); expect(mockResponse.setHeader).toHaveBeenCalledTimes(3); @@ -122,20 +128,29 @@ describe('oauth helpers', () => { }, }; sendWebMessageResponse(mockResponse, appOrigin, data); - expect(responseBody.match(/.postMessage\(/g)).toHaveLength(2); - expect( - responseBody.match(/.postMessage\([a-zA-z.()]*, \'\*\'\)/g), - ).toHaveLength(1); + 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(responseBody.match(/.postMessage\(/g)).toHaveLength(2); - expect( - responseBody.match(/.postMessage\([a-zA-z.()]*, \'\*\'\)/g), - ).toHaveLength(1); + expect(parseWebMessageResponse(responseBody).response).toEqual({ + type: 'authorization_response', + error: { + name: 'Error', + message: 'Unknown error occurred', + }, + }); }); it('handles single quotes and unicode chars safely', () => { diff --git a/plugins/auth-node/src/flow/sendWebMessageResponse.ts b/plugins/auth-node/src/flow/sendWebMessageResponse.ts index 7bb658bd11..4c7a85854d 100644 --- a/plugins/auth-node/src/flow/sendWebMessageResponse.ts +++ b/plugins/auth-node/src/flow/sendWebMessageResponse.ts @@ -17,6 +17,7 @@ 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. @@ -47,7 +48,12 @@ export function sendWebMessageResponse( appOrigin: string, response: WebMessageResponse, ): void { - const jsonData = JSON.stringify(response); + const jsonData = JSON.stringify(response, (_, value) => { + if (value instanceof Error) { + return serializeError(value); + } + return value; + }); const base64Data = safelyEncodeURIComponent(jsonData); const base64Origin = safelyEncodeURIComponent(appOrigin); diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts index 926d08fa77..2573d95ea1 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts @@ -24,8 +24,8 @@ import { createOAuthRouteHandlers } from './createOAuthRouteHandlers'; import { OAuthAuthenticator } from './types'; import { errorHandler } from '@backstage/backend-common'; import { encodeOAuthState, OAuthState } from './state'; -import { WebMessageResponse } from '../flow'; import { PassportProfile } from '../passport'; +import { parseWebMessageResponse } from '../flow/__testUtils__/parseWebMessageResponse'; const mockAuthenticator: jest.Mocked> = { initialize: jest.fn(_r => ({ ctx: 'authenticator' })), @@ -109,17 +109,6 @@ function getGrantedScopesCookie(test: SuperAgentTest) { }); } -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]), - }; -} - describe('createOAuthRouteHandlers', () => { afterEach(() => jest.clearAllMocks()); From b8515ae3b6e4bba87d8ee2cac399d53a2d764bab Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 11 Aug 2023 16:22:37 +0200 Subject: [PATCH 63/66] auth-node: fix OAuthState doc Signed-off-by: Patrik Oldsberg --- plugins/auth-node/api-report.md | 2 +- plugins/auth-node/src/oauth/state.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index 4a384834d6..a7fc9b1612 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -388,7 +388,7 @@ export interface OAuthSession { tokenType: string; } -// @public (undocumented) +// @public export type OAuthState = { nonce: string; env: string; diff --git a/plugins/auth-node/src/oauth/state.ts b/plugins/auth-node/src/oauth/state.ts index c5b96414fa..fc747d08a5 100644 --- a/plugins/auth-node/src/oauth/state.ts +++ b/plugins/auth-node/src/oauth/state.ts @@ -18,10 +18,11 @@ import pickBy from 'lodash/pickBy'; import { Request } from 'express'; import { NotAllowedError } from '@backstage/errors'; -/** @public */ +/** + * A type for the serialized value in the `state` parameter of the OAuth authorization flow + * @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; From 02ea2388d67f7b3dee08d6a5b72fa4ca7bea3886 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 11 Aug 2023 16:26:25 +0200 Subject: [PATCH 64/66] auth-node: avoid atob Signed-off-by: Patrik Oldsberg --- plugins/auth-node/src/passport/PassportHelpers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/plugins/auth-node/src/passport/PassportHelpers.ts b/plugins/auth-node/src/passport/PassportHelpers.ts index d24163bf34..6c13523811 100644 --- a/plugins/auth-node/src/passport/PassportHelpers.ts +++ b/plugins/auth-node/src/passport/PassportHelpers.ts @@ -37,7 +37,10 @@ function decodeJwtPayload(token: string): Record { let payload: unknown; try { payload = JSON.parse( - atob(payloadStr.replace(/-/g, '+').replace(/_/g, '/')), + Buffer.from( + payloadStr.replace(/-/g, '+').replace(/_/g, '/'), + 'base64', + ).toString('utf8'), ); } catch (e) { throw new Error('Invalid JWT token'); From f5eff800fd8eebf5156e8a6137562866ed2e7e38 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 11 Aug 2023 16:27:24 +0200 Subject: [PATCH 65/66] auth-node: tweaked some error types Signed-off-by: Patrik Oldsberg --- plugins/auth-node/src/proxy/createProxyRouteHandlers.ts | 5 +++-- plugins/auth-node/src/sign-in/createSignInResolverFactory.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/auth-node/src/proxy/createProxyRouteHandlers.ts b/plugins/auth-node/src/proxy/createProxyRouteHandlers.ts index 10ad62d2bf..521fe39288 100644 --- a/plugins/auth-node/src/proxy/createProxyRouteHandlers.ts +++ b/plugins/auth-node/src/proxy/createProxyRouteHandlers.ts @@ -25,6 +25,7 @@ import { } from '../types'; import { ProxyAuthenticator } from './types'; import { prepareBackstageIdentityResponse } from '../identity'; +import { NotImplementedError } from '@backstage/errors'; /** @public */ export interface ProxyAuthRouteHandlersOptions { @@ -47,11 +48,11 @@ export function createProxyAuthRouteHandlers( return { async start(): Promise { - throw new Error('Not implemented'); + throw new NotImplementedError('Not implemented'); }, async frameHandler(): Promise { - throw new Error('Not implemented'); + throw new NotImplementedError('Not implemented'); }, async refresh(this: never, req: Request, res: Response): Promise { diff --git a/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts b/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts index 6379eafd25..0632b704c3 100644 --- a/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts +++ b/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts @@ -18,6 +18,7 @@ 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 { @@ -55,7 +56,7 @@ export function createSignInResolverFactory< if (!optionsSchema) { return (resolverOptions?: TOptionsInput) => { if (resolverOptions) { - throw new Error('sign-in resolver does not accept options'); + throw new InputError('sign-in resolver does not accept options'); } return options.create(undefined as TOptionsOutput); }; From ee28fa94daef3bf47ee89a63f9501b8a08c76c16 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 11 Aug 2023 16:54:37 +0200 Subject: [PATCH 66/66] auth-node: minor review fixes Signed-off-by: Patrik Oldsberg --- .../auth-backend-module-gcp-iap-provider/config.d.ts | 10 ++++++++++ .../src/sign-in/readDeclarativeSignInResolver.ts | 6 ++++-- plugins/auth-node/src/types.ts | 6 +++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/plugins/auth-backend-module-gcp-iap-provider/config.d.ts b/plugins/auth-backend-module-gcp-iap-provider/config.d.ts index c8460470af..945378ada6 100644 --- a/plugins/auth-backend-module-gcp-iap-provider/config.d.ts +++ b/plugins/auth-backend-module-gcp-iap-provider/config.d.ts @@ -17,10 +17,20 @@ 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; }; }; diff --git a/plugins/auth-node/src/sign-in/readDeclarativeSignInResolver.ts b/plugins/auth-node/src/sign-in/readDeclarativeSignInResolver.ts index 31c4dd92a9..82b4918326 100644 --- a/plugins/auth-node/src/sign-in/readDeclarativeSignInResolver.ts +++ b/plugins/auth-node/src/sign-in/readDeclarativeSignInResolver.ts @@ -45,7 +45,9 @@ export function readDeclarativeSignInResolver( const { resolver: _ignored, ...resolverOptions } = resolverConfig.get(); - return resolver(resolverOptions); + return resolver( + Object.keys(resolverOptions).length > 0 ? resolverOptions : undefined, + ); }) ?? []; if (resolvers.length === 0) { @@ -53,7 +55,7 @@ export function readDeclarativeSignInResolver( } return async (profile, context) => { - for (const resolver of resolvers ?? []) { + for (const resolver of resolvers) { try { return await resolver(profile, context); } catch (error) { diff --git a/plugins/auth-node/src/types.ts b/plugins/auth-node/src/types.ts index ccdfb0c39b..d52abf00fb 100644 --- a/plugins/auth-node/src/types.ts +++ b/plugins/auth-node/src/types.ts @@ -106,7 +106,7 @@ export type AuthResolverCatalogUserQuery = }; /** - * Parameters used to issue new ID Tokens + * Parameters used to issue new Backstage Tokens * * @public */ @@ -199,6 +199,10 @@ export interface AuthProviderRouteHandlers { * (Optional) If the auth provider supports refresh tokens then this method handles * requests to get a new access token. * + * Other types of providers may also use this method to implement its own logic to create new sessions + * upon request. For example, this can be used to create a new session for a provider that handles requests + * from an authenticating proxy. + * * Request * - to contain a refresh token cookie and scope (Optional) query parameter. * Response