diff --git a/.changeset/warm-panthers-lick.md b/.changeset/warm-panthers-lick.md new file mode 100644 index 0000000000..b06809fac1 --- /dev/null +++ b/.changeset/warm-panthers-lick.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-github-provider': minor +--- + +New module for `@backstage/plugin-auth-backend` that adds a GitHub auth provider. diff --git a/plugins/auth-backend-module-github-provider/.eslintrc.js b/plugins/auth-backend-module-github-provider/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-backend-module-github-provider/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-backend-module-github-provider/README.md b/plugins/auth-backend-module-github-provider/README.md new file mode 100644 index 0000000000..7928ce1034 --- /dev/null +++ b/plugins/auth-backend-module-github-provider/README.md @@ -0,0 +1,8 @@ +# Auth Module: GitHub Provider + +This module provides an GitHub auth provider implementation for `@backstage/plugin-auth-backend`. + +## Links + +- [Backstage](https://backstage.io) +- [Repository](https://github.com/backstage/backstage/tree/master/plugins/auth-backend-module-github-provider) diff --git a/plugins/auth-backend-module-github-provider/api-report.md b/plugins/auth-backend-module-github-provider/api-report.md new file mode 100644 index 0000000000..2d0b33334c --- /dev/null +++ b/plugins/auth-backend-module-github-provider/api-report.md @@ -0,0 +1,29 @@ +## API Report File for "@backstage/plugin-auth-backend-module-github-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 authModuleGithubProvider: () => BackendFeature; + +// @public (undocumented) +export const githubAuthenticator: OAuthAuthenticator< + PassportOAuthAuthenticatorHelper, + PassportProfile +>; + +// @public +export namespace githubSignInResolvers { + const usernameMatchingUserEntityName: SignInResolverFactory< + OAuthAuthenticatorResult, + unknown + >; +} +``` diff --git a/plugins/auth-backend-module-github-provider/dev/index.ts b/plugins/auth-backend-module-github-provider/dev/index.ts new file mode 100644 index 0000000000..9ea084004a --- /dev/null +++ b/plugins/auth-backend-module-github-provider/dev/index.ts @@ -0,0 +1,26 @@ +/* + * 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 { createBackend } from '@backstage/backend-defaults'; +import { authPlugin } from '@backstage/plugin-auth-backend'; +import { authModuleGithubProvider } from '../src'; + +const backend = createBackend(); + +backend.add(authPlugin); +backend.add(authModuleGithubProvider); + +backend.start(); diff --git a/plugins/auth-backend-module-github-provider/package.json b/plugins/auth-backend-module-github-provider/package.json new file mode 100644 index 0000000000..d436e512ea --- /dev/null +++ b/plugins/auth-backend-module-github-provider/package.json @@ -0,0 +1,41 @@ +{ + "name": "@backstage/plugin-auth-backend-module-github-provider", + "description": "The github-provider backend module for the auth plugin.", + "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" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "passport-github2": "^0.1.12" + }, + "devDependencies": { + "@backstage/backend-defaults": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^", + "@backstage/plugin-auth-backend": "workspace:^", + "supertest": "^6.3.3" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/auth-backend-module-github-provider/src/authenticator.ts b/plugins/auth-backend-module-github-provider/src/authenticator.ts new file mode 100644 index 0000000000..f59ac3d727 --- /dev/null +++ b/plugins/auth-backend-module-github-provider/src/authenticator.ts @@ -0,0 +1,125 @@ +/* + * 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 as GithubStrategy } from 'passport-github2'; +import { + createOAuthAuthenticator, + PassportOAuthAuthenticatorHelper, + PassportOAuthDoneCallback, + PassportProfile, +} from '@backstage/plugin-auth-node'; + +const ACCESS_TOKEN_PREFIX = 'access-token.'; + +/** @public */ +export const githubAuthenticator = createOAuthAuthenticator({ + defaultProfileTransform: + PassportOAuthAuthenticatorHelper.defaultProfileTransform, + initialize({ callbackUrl, config }) { + const clientId = config.getString('clientId'); + const clientSecret = config.getString('clientSecret'); + const enterpriseInstanceUrl = config + .getOptionalString('enterpriseInstanceUrl') + ?.replace(/\/$/, ''); + const authorizationUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/login/oauth/authorize` + : undefined; + const tokenUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/login/oauth/access_token` + : undefined; + const userProfileUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/api/v3/user` + : undefined; + + return PassportOAuthAuthenticatorHelper.from( + new GithubStrategy( + { + clientID: clientId, + clientSecret: clientSecret, + callbackURL: callbackUrl, + tokenURL: tokenUrl, + userProfileURL: userProfileUrl, + authorizationURL: authorizationUrl, + }, + ( + 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) { + const { fullProfile, session } = await helper.authenticate(input); + + // If we do not have a real refresh token and we have a non-expiring + // access token, then we use that as our refresh token. + if (!session.refreshToken && !session.expiresInSeconds) { + session.refreshToken = ACCESS_TOKEN_PREFIX + session.accessToken; + } + return { fullProfile, session }; + }, + + async refresh(input, helper) { + // This is the OAuth App flow. A non-expiring access token is stored in the + // refresh token cookie. We use that token to fetch the user profile and + // refresh the Backstage session when needed. + if (input.refreshToken?.startsWith(ACCESS_TOKEN_PREFIX)) { + const accessToken = input.refreshToken.slice(ACCESS_TOKEN_PREFIX.length); + + const fullProfile = await helper + .fetchProfile(accessToken) + .catch(error => { + if (error.oauthError?.statusCode === 401) { + throw new Error('Invalid access token'); + } + throw error; + }); + + return { + fullProfile, + session: { + accessToken, + tokenType: 'bearer', + scope: input.scope, + refreshToken: input.refreshToken, + // No expiration + }, + }; + } + + // This is the App flow, which is close to a standard OAuth refresh flow. It has a + // pretty long session expiration, and it also ignores the requested scope, instead + // just allowing access to whatever is configured as part of the app installation. + return helper.refresh(input); + }, +}); diff --git a/plugins/auth-backend-module-github-provider/src/index.ts b/plugins/auth-backend-module-github-provider/src/index.ts new file mode 100644 index 0000000000..ba389cbdc7 --- /dev/null +++ b/plugins/auth-backend-module-github-provider/src/index.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * The github-provider backend module for the auth plugin. + * + * @packageDocumentation + */ + +export { githubAuthenticator } from './authenticator'; +export { authModuleGithubProvider } from './module'; +export { githubSignInResolvers } from './resolvers'; diff --git a/plugins/auth-backend-module-github-provider/src/module.ts b/plugins/auth-backend-module-github-provider/src/module.ts new file mode 100644 index 0000000000..54286c94a7 --- /dev/null +++ b/plugins/auth-backend-module-github-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, + createOAuthProviderFactory, +} from '@backstage/plugin-auth-node'; +import { githubAuthenticator } from './authenticator'; +import { githubSignInResolvers } from './resolvers'; + +/** @public */ +export const authModuleGithubProvider = createBackendModule({ + pluginId: 'auth', + moduleId: 'github-provider', + register(reg) { + reg.registerInit({ + deps: { + providers: authProvidersExtensionPoint, + }, + async init({ providers }) { + providers.registerProvider({ + providerId: 'github', + factory: createOAuthProviderFactory({ + authenticator: githubAuthenticator, + signInResolverFactories: { + ...githubSignInResolvers, + ...commonSignInResolvers, + }, + }), + }); + }, + }); + }, +}); diff --git a/plugins/auth-backend-module-github-provider/src/resolvers.ts b/plugins/auth-backend-module-github-provider/src/resolvers.ts new file mode 100644 index 0000000000..496080a33c --- /dev/null +++ b/plugins/auth-backend-module-github-provider/src/resolvers.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 { + createSignInResolverFactory, + OAuthAuthenticatorResult, + PassportProfile, + SignInInfo, +} from '@backstage/plugin-auth-node'; + +/** + * Available sign-in resolvers for the GitHub auth provider. + * + * @public + */ +export namespace githubSignInResolvers { + /** + * Looks up the user by matching their GitHub username to the entity name. + */ + export const usernameMatchingUserEntityName = createSignInResolverFactory({ + create() { + return async ( + info: SignInInfo>, + ctx, + ) => { + const { fullProfile } = info.result; + + const userId = fullProfile.username; + if (!userId) { + throw new Error(`GitHub user profile does not contain a username`); + } + + return ctx.signInWithCatalogUser({ entityRef: { name: userId } }); + }; + }, + }); +} diff --git a/yarn.lock b/yarn.lock index 1a10972dcb..69b8b92929 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4553,6 +4553,22 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-backend-module-github-provider@workspace:plugins/auth-backend-module-github-provider": + version: 0.0.0-use.local + resolution: "@backstage/plugin-auth-backend-module-github-provider@workspace:plugins/auth-backend-module-github-provider" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/backend-defaults": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/plugin-auth-backend": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + passport-github2: ^0.1.12 + supertest: ^6.3.3 + languageName: unknown + linkType: soft + "@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"