Merge pull request #24743 from backstage/rugvip/scopes
auth-node: refactor OAuth scope management
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
---
|
||||
'@backstage/plugin-auth-node': patch
|
||||
---
|
||||
|
||||
Updated scope management for OAuth providers, where the `createOAuthAuthenticator` now accepts a new collection of `scopes` options:
|
||||
|
||||
- `scopes.persist` - Whether scopes should be persisted, replaces the `shouldPersistScopes` option.
|
||||
- `scopes.required` - A list of required scopes that will always be requested.
|
||||
- `scopes.transform` - A function that can be used to transform the scopes before they are requested.
|
||||
|
||||
The `createOAuthProviderFactory` has also received a new `additionalScopes` option, and will also read `additionalScopes` from the auth provider configuration. Both of these can be used to add additional scopes that should always be requested.
|
||||
|
||||
A significant change under the hood that this new scope management brings is that providers that persist scopes will now always merge the already granted scopes with the requested ones. The previous behavior was that the full authorization flow would not include existing scopes, while the refresh flow would only include the existing scopes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-atlassian-provider': minor
|
||||
---
|
||||
|
||||
**BREAKING**: The `scope` and `scopes` config options have been removed and replaced by the standard `additionalScopes` config. In addition, the `offline_access`, `read:jira-work`, and `read:jira-user` scopes have been set to required and will always be present.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-bitbucket-provider': patch
|
||||
---
|
||||
|
||||
Added support for the new shared `additionalScopes` configuration. In addition, the `account` scope has been set to required and will always be present.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-github-provider': patch
|
||||
---
|
||||
|
||||
Added support for the new shared `additionalScopes` configuration. In addition, the `read:user` scope has been set to required and will always be present.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-gitlab-provider': patch
|
||||
---
|
||||
|
||||
Added support for the new shared `additionalScopes` configuration. In addition, the `read_user` scope has been set to required and will always be present.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-google-provider': patch
|
||||
---
|
||||
|
||||
Added support for the new shared `additionalScopes` configuration. In addition, the `openid`, `userinfo.email`, and `userinfo.profile` scopes have been set to required and will always be present.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-microsoft-provider': patch
|
||||
---
|
||||
|
||||
Added support for the new shared `additionalScopes` configuration.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-oauth2-provider': minor
|
||||
---
|
||||
|
||||
**BREAKING**: The `scope` config option have been removed and replaced by the standard `additionalScopes` config.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-oidc-provider': minor
|
||||
---
|
||||
|
||||
**BREAKING**: The `scope` config option have been removed and replaced by the standard `additionalScopes` config. In addition, `openid`, `profile`, and `email` scopes have been set to required and will always be present.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-okta-provider': patch
|
||||
---
|
||||
|
||||
Added support for the new shared `additionalScopes` configuration, which means it can now also be specified as an array. In addition, the `openid`, `email`, `profile`, and `offline_access` scopes have been set to required and will always be present.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-pinniped-provider': patch
|
||||
---
|
||||
|
||||
**BREAKING**: The `scope` config option have been removed and replaced by the standard `additionalScopes` config. In addition, the `openid`, `pinniped:request-audience`, `username`, and `offline_access` scopes have been set to required and will always be present.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-vmware-cloud-provider': minor
|
||||
---
|
||||
|
||||
**BREAKING**: The `scope` config option have been removed and replaced by the standard `additionalScopes` config. In addition, `openid`, and `offline_access` scopes have been set to required and will always be present.
|
||||
@@ -27,7 +27,7 @@ export interface Config {
|
||||
clientSecret: string;
|
||||
audience?: string;
|
||||
callbackUrl?: string;
|
||||
scope?: string;
|
||||
additionalScopes?: string | string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,15 +26,20 @@ import { Strategy as AtlassianStrategy } from 'passport-atlassian-oauth2';
|
||||
export const atlassianAuthenticator = createOAuthAuthenticator({
|
||||
defaultProfileTransform:
|
||||
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
|
||||
scopes: {
|
||||
required: ['offline_access', 'read:jira-work', 'read:jira-user'],
|
||||
},
|
||||
initialize({ callbackUrl, config }) {
|
||||
const clientId = config.getString('clientId');
|
||||
const clientSecret = config.getString('clientSecret');
|
||||
const scope =
|
||||
config.getOptionalString('scope') ??
|
||||
config.getOptionalString('scopes') ??
|
||||
'offline_access read:jira-work read:jira-user';
|
||||
const baseUrl = 'https://auth.atlassian.com';
|
||||
|
||||
if (config.has('scope') || config.has('scopes')) {
|
||||
throw new Error(
|
||||
'The atlassian provider no longer supports the "scope" or "scopes" configuration options. Please use the "additionalScopes" option instead.',
|
||||
);
|
||||
}
|
||||
|
||||
return PassportOAuthAuthenticatorHelper.from(
|
||||
new AtlassianStrategy(
|
||||
{
|
||||
@@ -45,7 +50,6 @@ export const atlassianAuthenticator = createOAuthAuthenticator({
|
||||
authorizationURL: `${baseUrl}/authorize`,
|
||||
tokenURL: `${baseUrl}/oauth/token`,
|
||||
profileURL: `${baseUrl}/api/v4/user`,
|
||||
scope,
|
||||
},
|
||||
(
|
||||
accessToken: string,
|
||||
|
||||
@@ -75,7 +75,8 @@ describe('authModuleAtlassianProvider', () => {
|
||||
nonce: decodeURIComponent(nonceCookie.value),
|
||||
});
|
||||
});
|
||||
it('should start with and use custom scopes from scope config field', async () => {
|
||||
|
||||
it('should start with and use custom scopes from additionalScopes config field', async () => {
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
import('@backstage/plugin-auth-backend'),
|
||||
@@ -91,7 +92,10 @@ describe('authModuleAtlassianProvider', () => {
|
||||
development: {
|
||||
clientId: 'my-client-id',
|
||||
clientSecret: 'my-client-secret',
|
||||
scope: 'offline_access read:filter:jira read:jira-work',
|
||||
additionalScopes: [
|
||||
'read:filter:jira',
|
||||
'read:jira-work', // already required
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -123,7 +127,7 @@ describe('authModuleAtlassianProvider', () => {
|
||||
client_id: 'my-client-id',
|
||||
redirect_uri: `http://localhost:${server.port()}/api/auth/atlassian/handler/frame`,
|
||||
state: expect.any(String),
|
||||
scope: 'offline_access read:filter:jira read:jira-work',
|
||||
scope: 'offline_access read:jira-work read:jira-user read:filter:jira',
|
||||
});
|
||||
|
||||
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
|
||||
@@ -131,60 +135,35 @@ describe('authModuleAtlassianProvider', () => {
|
||||
nonce: decodeURIComponent(nonceCookie.value),
|
||||
});
|
||||
});
|
||||
it('should start with and use custom scopes from scopes config field for backward compatibility', async () => {
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
import('@backstage/plugin-auth-backend'),
|
||||
authModuleAtlassianProvider,
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
app: {
|
||||
baseUrl: 'http://localhost:3000',
|
||||
},
|
||||
auth: {
|
||||
providers: {
|
||||
atlassian: {
|
||||
development: {
|
||||
clientId: 'my-client-id',
|
||||
clientSecret: 'my-client-secret',
|
||||
scopes: 'offline_access read:filter:jira read:jira-work',
|
||||
|
||||
it('should fail to start with scope or scopes config', async () => {
|
||||
await expect(
|
||||
startTestBackend({
|
||||
features: [
|
||||
import('@backstage/plugin-auth-backend'),
|
||||
authModuleAtlassianProvider,
|
||||
mockServices.rootConfig.factory({
|
||||
data: {
|
||||
app: {
|
||||
baseUrl: 'http://localhost:3000',
|
||||
},
|
||||
auth: {
|
||||
providers: {
|
||||
atlassian: {
|
||||
development: {
|
||||
clientId: 'my-client-id',
|
||||
clientSecret: 'my-client-secret',
|
||||
scope: 'foo',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const agent = request.agent(server);
|
||||
|
||||
const res = await agent.get('/api/auth/atlassian/start?env=development');
|
||||
|
||||
expect(res.status).toEqual(302);
|
||||
|
||||
const nonceCookie = agent.jar.getCookie('atlassian-nonce', {
|
||||
domain: 'localhost',
|
||||
path: '/api/auth/atlassian/handler',
|
||||
script: false,
|
||||
secure: false,
|
||||
});
|
||||
expect(nonceCookie).toBeDefined();
|
||||
|
||||
const startUrl = new URL(res.get('location'));
|
||||
expect(startUrl.origin).toBe('https://auth.atlassian.com');
|
||||
expect(startUrl.pathname).toBe('/authorize');
|
||||
expect(Object.fromEntries(startUrl.searchParams)).toEqual({
|
||||
response_type: 'code',
|
||||
client_id: 'my-client-id',
|
||||
redirect_uri: `http://localhost:${server.port()}/api/auth/atlassian/handler/frame`,
|
||||
state: expect.any(String),
|
||||
scope: 'offline_access read:filter:jira read:jira-work',
|
||||
});
|
||||
|
||||
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
|
||||
env: 'development',
|
||||
nonce: decodeURIComponent(nonceCookie.value),
|
||||
});
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/atlassian provider no longer supports the "scope" or "scopes" configuration options/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface Config {
|
||||
* @visibility secret
|
||||
*/
|
||||
clientSecret: string;
|
||||
additionalScopes?: string | string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
export const bitbucketAuthenticator = createOAuthAuthenticator({
|
||||
defaultProfileTransform:
|
||||
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
|
||||
scopes: {
|
||||
required: ['account'],
|
||||
},
|
||||
initialize({ callbackUrl, config }) {
|
||||
const clientID = config.getString('clientId');
|
||||
const clientSecret = config.getString('clientSecret');
|
||||
|
||||
@@ -67,6 +67,7 @@ describe('authModuleBitbucketProvider', () => {
|
||||
client_id: 'my-client-id',
|
||||
redirect_uri: `http://localhost:${server.port()}/api/auth/bitbucket/handler/frame`,
|
||||
state: expect.any(String),
|
||||
scope: 'account',
|
||||
});
|
||||
|
||||
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface Config {
|
||||
clientSecret: string;
|
||||
callbackUrl?: string;
|
||||
enterpriseInstanceUrl?: string;
|
||||
additionalScopes?: string | string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -28,7 +28,10 @@ const ACCESS_TOKEN_PREFIX = 'access-token.';
|
||||
export const githubAuthenticator = createOAuthAuthenticator({
|
||||
defaultProfileTransform:
|
||||
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
|
||||
shouldPersistScopes: true,
|
||||
scopes: {
|
||||
persist: true,
|
||||
required: ['read:user'],
|
||||
},
|
||||
initialize({ callbackUrl, config }) {
|
||||
const clientId = config.getString('clientId');
|
||||
const clientSecret = config.getString('clientSecret');
|
||||
|
||||
@@ -67,11 +67,13 @@ describe('authModuleGithubProvider', () => {
|
||||
client_id: 'my-client-id',
|
||||
redirect_uri: `http://localhost:${server.port()}/api/auth/github/handler/frame`,
|
||||
state: expect.any(String),
|
||||
scope: 'read:user',
|
||||
});
|
||||
|
||||
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
|
||||
env: 'development',
|
||||
nonce: decodeURIComponent(nonceCookie.value),
|
||||
scope: 'read:user',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface Config {
|
||||
clientSecret: string;
|
||||
audience?: string;
|
||||
callbackUrl?: string;
|
||||
additionalScopes?: string | string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
export const gitlabAuthenticator = createOAuthAuthenticator({
|
||||
defaultProfileTransform:
|
||||
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
|
||||
scopes: {
|
||||
required: ['read_user'],
|
||||
},
|
||||
initialize({ callbackUrl, config }) {
|
||||
const clientId = config.getString('clientId');
|
||||
const clientSecret = config.getString('clientSecret');
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface Config {
|
||||
*/
|
||||
clientSecret: string;
|
||||
callbackUrl?: string;
|
||||
additionalScopes?: string | string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -27,6 +27,13 @@ import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||
export const googleAuthenticator = createOAuthAuthenticator({
|
||||
defaultProfileTransform:
|
||||
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
|
||||
scopes: {
|
||||
required: [
|
||||
'openid',
|
||||
`https://www.googleapis.com/auth/userinfo.email`,
|
||||
`https://www.googleapis.com/auth/userinfo.profile`,
|
||||
],
|
||||
},
|
||||
initialize({ callbackUrl, config }) {
|
||||
const clientId = config.getString('clientId');
|
||||
const clientSecret = config.getString('clientSecret');
|
||||
|
||||
@@ -69,6 +69,8 @@ describe('authModuleGoogleProvider', () => {
|
||||
client_id: 'my-client-id',
|
||||
redirect_uri: `http://localhost:${server.port()}/api/auth/google/handler/frame`,
|
||||
state: expect.any(String),
|
||||
scope:
|
||||
'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile',
|
||||
});
|
||||
|
||||
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface Config {
|
||||
clientSecret: string;
|
||||
domainHint?: string;
|
||||
callbackUrl?: string;
|
||||
additionalScopes?: string[];
|
||||
additionalScopes?: string | string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('authModuleMicrosoftProvider', () => {
|
||||
expect(startUrl.pathname).toBe('/my-tenant-id/oauth2/v2.0/authorize');
|
||||
expect(Object.fromEntries(startUrl.searchParams)).toEqual({
|
||||
response_type: 'code',
|
||||
scope: 'user.read User.Read.All',
|
||||
scope: 'User.Read.All',
|
||||
client_id: 'my-client-id',
|
||||
redirect_uri: `http://localhost:${server.port()}/api/auth/microsoft/handler/frame`,
|
||||
state: expect.any(String),
|
||||
|
||||
@@ -27,7 +27,9 @@ export interface Config {
|
||||
clientSecret: string;
|
||||
authorizationUrl: string;
|
||||
tokenUrl: string;
|
||||
/** @deprecated use `additionalScopes` instead */
|
||||
scope?: string;
|
||||
additionalScopes?: string | string[];
|
||||
disableRefresh?: boolean;
|
||||
includeBasicAuth?: boolean;
|
||||
};
|
||||
|
||||
@@ -31,9 +31,14 @@ export const oauth2Authenticator = createOAuthAuthenticator({
|
||||
const clientSecret = config.getString('clientSecret');
|
||||
const authorizationUrl = config.getString('authorizationUrl');
|
||||
const tokenUrl = config.getString('tokenUrl');
|
||||
const scope = config.getOptionalString('scope');
|
||||
const includeBasicAuth = config.getOptionalBoolean('includeBasicAuth');
|
||||
|
||||
if (config.has('scope')) {
|
||||
throw new Error(
|
||||
'The oauth2 provider no longer supports the "scope" configuration option. Please use the "additionalScopes" option instead.',
|
||||
);
|
||||
}
|
||||
|
||||
return PassportOAuthAuthenticatorHelper.from(
|
||||
new Oauth2Strategy(
|
||||
{
|
||||
@@ -43,7 +48,6 @@ export const oauth2Authenticator = createOAuthAuthenticator({
|
||||
authorizationURL: authorizationUrl,
|
||||
tokenURL: tokenUrl,
|
||||
passReqToCallback: false,
|
||||
scope: scope,
|
||||
customHeaders: includeBasicAuth
|
||||
? {
|
||||
Authorization: `Basic ${encodeClientCredentials(
|
||||
|
||||
@@ -19,7 +19,6 @@ export default authModuleOidcProvider;
|
||||
// @public (undocumented)
|
||||
export const oidcAuthenticator: OAuthAuthenticator<
|
||||
{
|
||||
initializedScope: string | undefined;
|
||||
initializedPrompt: string | undefined;
|
||||
promise: Promise<{
|
||||
helper: PassportOAuthAuthenticatorHelper;
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ export interface Config {
|
||||
callbackUrl?: string;
|
||||
tokenEndpointAuthMethod?: string;
|
||||
tokenSignedResponseAlg?: string;
|
||||
scope?: string;
|
||||
additionalScopes?: string | string[];
|
||||
prompt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -254,19 +254,6 @@ describe('oidcAuthenticator', () => {
|
||||
expect(fakeSession['oidc:oidc.test'].code_verifier).toBeDefined();
|
||||
});
|
||||
|
||||
it('requests default scopes if none are provided in config', async () => {
|
||||
const startResponse = await oidcAuthenticator.start(
|
||||
startRequest,
|
||||
implementation,
|
||||
);
|
||||
const { searchParams } = new URL(startResponse.url);
|
||||
const scopes = searchParams.get('scope')?.split(' ') ?? [];
|
||||
|
||||
expect(scopes).toEqual(
|
||||
expect.arrayContaining(['openid', 'profile', 'email']),
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes OAuth state in query param', async () => {
|
||||
const startResponse = await oidcAuthenticator.start(
|
||||
startRequest,
|
||||
|
||||
@@ -53,7 +53,6 @@ export type OidcAuthResult = {
|
||||
|
||||
/** @public */
|
||||
export const oidcAuthenticator = createOAuthAuthenticator({
|
||||
shouldPersistScopes: true,
|
||||
defaultProfileTransform: async (
|
||||
input: OAuthAuthenticatorResult<OidcAuthResult>,
|
||||
) => ({
|
||||
@@ -63,6 +62,10 @@ export const oidcAuthenticator = createOAuthAuthenticator({
|
||||
displayName: input.fullProfile.userinfo.name,
|
||||
},
|
||||
}),
|
||||
scopes: {
|
||||
persist: true,
|
||||
required: ['openid', 'profile', 'email'],
|
||||
},
|
||||
initialize({ callbackUrl, config }) {
|
||||
const clientId = config.getString('clientId');
|
||||
const clientSecret = config.getString('clientSecret');
|
||||
@@ -74,9 +77,14 @@ export const oidcAuthenticator = createOAuthAuthenticator({
|
||||
const tokenSignedResponseAlg = config.getOptionalString(
|
||||
'tokenSignedResponseAlg',
|
||||
);
|
||||
const initializedScope = config.getOptionalString('scope');
|
||||
const initializedPrompt = config.getOptionalString('prompt');
|
||||
|
||||
if (config.has('scope')) {
|
||||
throw new Error(
|
||||
'The oidc provider no longer supports the "scope" configuration option. Please use the "additionalScopes" option instead.',
|
||||
);
|
||||
}
|
||||
|
||||
Issuer[custom.http_options] = httpOptionsProvider;
|
||||
const promise = Issuer.discover(metadataUrl).then(issuer => {
|
||||
issuer[custom.http_options] = httpOptionsProvider;
|
||||
@@ -91,7 +99,6 @@ export const oidcAuthenticator = createOAuthAuthenticator({
|
||||
token_endpoint_auth_method:
|
||||
tokenEndpointAuthMethod || 'client_secret_basic',
|
||||
id_token_signed_response_alg: tokenSignedResponseAlg || 'RS256',
|
||||
scope: initializedScope || '',
|
||||
});
|
||||
client[custom.http_options] = httpOptionsProvider;
|
||||
|
||||
@@ -123,14 +130,14 @@ export const oidcAuthenticator = createOAuthAuthenticator({
|
||||
return { helper, client, strategy };
|
||||
});
|
||||
|
||||
return { initializedScope, initializedPrompt, promise };
|
||||
return { initializedPrompt, promise };
|
||||
},
|
||||
|
||||
async start(input, ctx) {
|
||||
const { initializedScope, initializedPrompt, promise } = ctx;
|
||||
const { initializedPrompt, promise } = ctx;
|
||||
const { helper, strategy } = await promise;
|
||||
const options: Record<string, string> = {
|
||||
scope: input.scope || initializedScope || 'openid profile email',
|
||||
scope: input.scope,
|
||||
state: input.state,
|
||||
nonce: crypto.randomBytes(16).toString('base64'),
|
||||
};
|
||||
|
||||
@@ -212,6 +212,7 @@ describe('authModuleOidcProvider', () => {
|
||||
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
|
||||
env: 'development',
|
||||
nonce: decodeURIComponent(nonceCookie.value),
|
||||
scope: 'openid profile email',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ export interface Config {
|
||||
authServerId?: string;
|
||||
idp?: string;
|
||||
callbackUrl?: string;
|
||||
additionalScopes?: string;
|
||||
additionalScopes?: string | string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,25 +26,15 @@ import {
|
||||
export const oktaAuthenticator = createOAuthAuthenticator({
|
||||
defaultProfileTransform:
|
||||
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
|
||||
scopes: {
|
||||
required: ['openid', 'email', 'profile', 'offline_access'],
|
||||
},
|
||||
initialize({ callbackUrl, config }) {
|
||||
const clientId = config.getString('clientId');
|
||||
const clientSecret = config.getString('clientSecret');
|
||||
const audience = config.getOptionalString('audience') || 'https://okta.com';
|
||||
const authServerId = config.getOptionalString('authServerId');
|
||||
const idp = config.getOptionalString('idp');
|
||||
// default scopes are taken from
|
||||
// https://developer.okta.com/docs/reference/api/oidc/#response-example-success-refresh-token
|
||||
const defaultScopes = 'openid profile email';
|
||||
// additional scopes can be configured in the config as a space separated string
|
||||
const additionalScopes = config.getOptionalString('additionalScopes') || '';
|
||||
// combine default and additional scopes and remove duplicates
|
||||
const combineScopeStrings = (scopesA: string, scopesB: string) => {
|
||||
const scopesAArray = scopesA.split(' ');
|
||||
const scopesBArray = scopesB.split(' ');
|
||||
const combinedScopes = new Set([...scopesAArray, ...scopesBArray]);
|
||||
return Array.from(combinedScopes).join(' ');
|
||||
};
|
||||
const scope = combineScopeStrings(defaultScopes, additionalScopes);
|
||||
|
||||
return PassportOAuthAuthenticatorHelper.from(
|
||||
new OktaStrategy(
|
||||
@@ -57,7 +47,6 @@ export const oktaAuthenticator = createOAuthAuthenticator({
|
||||
idp: idp,
|
||||
passReqToCallback: false,
|
||||
response_type: 'code',
|
||||
scope,
|
||||
},
|
||||
(
|
||||
accessToken: string,
|
||||
|
||||
@@ -21,9 +21,6 @@ import { decodeOAuthState } from '@backstage/plugin-auth-node';
|
||||
|
||||
describe('authModuleOktaProvider', () => {
|
||||
it('should start', async () => {
|
||||
const defaultScopes = 'openid profile email';
|
||||
const additionalScopes = 'groups phone';
|
||||
const combinedScopes = `${defaultScopes} ${additionalScopes}`;
|
||||
const { server } = await startTestBackend({
|
||||
features: [
|
||||
import('@backstage/plugin-auth-backend'),
|
||||
@@ -39,7 +36,7 @@ describe('authModuleOktaProvider', () => {
|
||||
development: {
|
||||
clientId: 'my-client-id',
|
||||
clientSecret: 'my-client-secret',
|
||||
additionalScopes,
|
||||
additionalScopes: 'groups phone',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -68,7 +65,7 @@ describe('authModuleOktaProvider', () => {
|
||||
expect(startUrl.pathname).toBe('/oauth2/v1/authorize');
|
||||
expect(Object.fromEntries(startUrl.searchParams)).toEqual({
|
||||
response_type: 'code',
|
||||
scope: combinedScopes,
|
||||
scope: 'openid email profile offline_access groups phone',
|
||||
client_id: 'my-client-id',
|
||||
redirect_uri: `http://localhost:${server.port()}/api/auth/okta/handler/frame`,
|
||||
state: expect.any(String),
|
||||
|
||||
@@ -249,22 +249,14 @@ describe('pinnipedAuthenticator', () => {
|
||||
expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined();
|
||||
});
|
||||
|
||||
it('requests sufficient scopes for token exchange by default', async () => {
|
||||
it('forwards scopes for token exchange', async () => {
|
||||
const startResponse = await pinnipedAuthenticator.start(
|
||||
startRequest,
|
||||
{ ...startRequest, scope: 'openid username' },
|
||||
authCtx,
|
||||
);
|
||||
const { searchParams } = new URL(startResponse.url);
|
||||
const scopes = searchParams.get('scope')?.split(' ') ?? [];
|
||||
|
||||
expect(scopes).toEqual(
|
||||
expect.arrayContaining([
|
||||
'openid',
|
||||
'pinniped:request-audience',
|
||||
'username',
|
||||
'offline_access',
|
||||
]),
|
||||
);
|
||||
expect(searchParams.get('scope')).toBe('openid username');
|
||||
});
|
||||
|
||||
it('encodes OAuth state in query param', async () => {
|
||||
|
||||
@@ -127,7 +127,6 @@ export class PinnipedStrategyCache {
|
||||
client_secret: this.config.getString('clientSecret'),
|
||||
redirect_uris: [this.callbackUrl],
|
||||
response_types: ['code'],
|
||||
scope: this.config.getOptionalString('scope') || '',
|
||||
id_token_signed_response_alg: 'ES256',
|
||||
});
|
||||
const providerStrategy = new OidcStrategy(
|
||||
@@ -154,7 +153,20 @@ export class PinnipedStrategyCache {
|
||||
/** @public */
|
||||
export const pinnipedAuthenticator = createOAuthAuthenticator({
|
||||
defaultProfileTransform: async (_r, _c) => ({ profile: {} }),
|
||||
scopes: {
|
||||
required: [
|
||||
'openid',
|
||||
'pinniped:request-audience',
|
||||
'username',
|
||||
'offline_access',
|
||||
],
|
||||
},
|
||||
initialize({ callbackUrl, config }) {
|
||||
if (config.has('scope')) {
|
||||
throw new Error(
|
||||
'The pinniped provider no longer supports the "scope" configuration option. Please use the "additionalScopes" option instead.',
|
||||
);
|
||||
}
|
||||
return new PinnipedStrategyCache(callbackUrl, config);
|
||||
},
|
||||
async start(input, ctx): Promise<{ url: string; status?: number }> {
|
||||
@@ -163,9 +175,7 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({
|
||||
const decodedState = decodeOAuthState(input.state);
|
||||
const state = { ...decodedState, audience: stringifiedAudience };
|
||||
const options: Record<string, string> = {
|
||||
scope:
|
||||
input.scope ||
|
||||
'openid pinniped:request-audience username offline_access',
|
||||
scope: input.scope,
|
||||
state: encodeOAuthState(state),
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface Config {
|
||||
organizationId: string;
|
||||
scope?: string;
|
||||
consoleEndpoint?: string;
|
||||
additionalScopes?: string | string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -163,9 +163,9 @@ describe('vmwareCloudAuthenticator', () => {
|
||||
expect(searchParams.get('redirect_uri')).toBe('http://callbackUrl');
|
||||
});
|
||||
|
||||
it('requests scopes for ID and refresh token', async () => {
|
||||
it('forwards scopes for ID and refresh token', async () => {
|
||||
const startResponse = await vmwareCloudAuthenticator.start(
|
||||
startRequest,
|
||||
{ ...startRequest, scope: 'openid offline_access' },
|
||||
authenticatorCtx,
|
||||
);
|
||||
const { searchParams } = new URL(startResponse.url);
|
||||
|
||||
@@ -96,6 +96,9 @@ export const vmwareCloudAuthenticator = createOAuthAuthenticator<
|
||||
},
|
||||
};
|
||||
},
|
||||
scopes: {
|
||||
required: ['openid', 'offline_access'],
|
||||
},
|
||||
initialize({ callbackUrl, config }) {
|
||||
const consoleEndpoint =
|
||||
config.getOptionalString('consoleEndpoint') ??
|
||||
@@ -106,7 +109,12 @@ export const vmwareCloudAuthenticator = createOAuthAuthenticator<
|
||||
const clientSecret = '';
|
||||
const authorizationUrl = `${consoleEndpoint}/csp/gateway/discovery`;
|
||||
const tokenUrl = `${consoleEndpoint}/csp/gateway/am/api/auth/token`;
|
||||
const scope = config.getOptionalString('scope') ?? 'openid offline_access';
|
||||
|
||||
if (config.has('scope')) {
|
||||
throw new Error(
|
||||
'The vmware-cloud provider no longer supports the "scope" configuration option. Please use the "additionalScopes" option instead.',
|
||||
);
|
||||
}
|
||||
|
||||
const providerStrategy = new OAuth2Strategy(
|
||||
{
|
||||
@@ -118,7 +126,6 @@ export const vmwareCloudAuthenticator = createOAuthAuthenticator<
|
||||
passReqToCallback: false,
|
||||
pkce: true,
|
||||
state: true,
|
||||
scope: scope,
|
||||
customHeaders: {
|
||||
Authorization: `Basic ${encodeClientCredentials(
|
||||
clientId,
|
||||
|
||||
@@ -173,6 +173,7 @@ export function createOAuthAuthenticator<TContext, TProfile>(
|
||||
// @public (undocumented)
|
||||
export function createOAuthProviderFactory<TProfile>(options: {
|
||||
authenticator: OAuthAuthenticator<unknown, TProfile>;
|
||||
additionalScopes?: string[];
|
||||
stateTransform?: OAuthStateTransform;
|
||||
profileTransform?: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
|
||||
signInResolver?: SignInResolver<OAuthAuthenticatorResult<TProfile>>;
|
||||
@@ -291,6 +292,8 @@ export interface OAuthAuthenticator<TContext, TProfile> {
|
||||
ctx: TContext,
|
||||
): Promise<OAuthAuthenticatorResult<TProfile>>;
|
||||
// (undocumented)
|
||||
scopes?: OAuthAuthenticatorScopeOptions;
|
||||
// @deprecated (undocumented)
|
||||
shouldPersistScopes?: boolean;
|
||||
// (undocumented)
|
||||
start(
|
||||
@@ -336,6 +339,21 @@ export interface OAuthAuthenticatorResult<TProfile> {
|
||||
session: OAuthSession;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface OAuthAuthenticatorScopeOptions {
|
||||
// (undocumented)
|
||||
persist?: boolean;
|
||||
// (undocumented)
|
||||
required?: string[];
|
||||
// (undocumented)
|
||||
transform?: (options: {
|
||||
requested: Iterable<string>;
|
||||
granted: Iterable<string>;
|
||||
required: Iterable<string>;
|
||||
additional: Iterable<string>;
|
||||
}) => Iterable<string>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface OAuthAuthenticatorStartInput {
|
||||
// (undocumented)
|
||||
@@ -366,6 +384,8 @@ export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers {
|
||||
|
||||
// @public (undocumented)
|
||||
export interface OAuthRouteHandlersOptions<TProfile> {
|
||||
// (undocumented)
|
||||
additionalScopes?: string[];
|
||||
// (undocumented)
|
||||
appUrl: string;
|
||||
// (undocumented)
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* Copyright 2024 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 {
|
||||
OAuthAuthenticator,
|
||||
OAuthAuthenticatorResult,
|
||||
OAuthAuthenticatorScopeOptions,
|
||||
} from './types';
|
||||
import { OAuthCookieManager } from './OAuthCookieManager';
|
||||
import { OAuthState } from './state';
|
||||
import { CookieScopeManager } from './CookieScopeManager';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
|
||||
function makeReq(scope?: string): express.Request {
|
||||
return {
|
||||
query: { scope },
|
||||
res: {},
|
||||
get: _name => 'https://example.com',
|
||||
} as express.Request<any, any, any, any>;
|
||||
}
|
||||
|
||||
const baseOpts = {
|
||||
authenticator: {} as OAuthAuthenticator<any, any>,
|
||||
cookieManager: {} as OAuthCookieManager,
|
||||
};
|
||||
|
||||
describe('CookieScopeManager', () => {
|
||||
it('should work with minimal config', async () => {
|
||||
const manager = CookieScopeManager.create({
|
||||
authenticator: {} as OAuthAuthenticator<any, any>,
|
||||
cookieManager: {} as OAuthCookieManager,
|
||||
});
|
||||
|
||||
await expect(manager.start(makeReq())).resolves.toEqual({
|
||||
scope: '',
|
||||
});
|
||||
await expect(manager.start(makeReq('x'))).resolves.toEqual({
|
||||
scope: 'x',
|
||||
});
|
||||
await expect(manager.start(makeReq('x,y'))).resolves.toEqual({
|
||||
scope: 'x y',
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.handleCallback(makeReq(), {
|
||||
result: { session: { scope: 'x,y' } } as OAuthAuthenticatorResult<any>,
|
||||
state: {} as OAuthState,
|
||||
}),
|
||||
).resolves.toEqual('x y');
|
||||
|
||||
await expect(manager.clear(makeReq())).resolves.toBe(undefined);
|
||||
|
||||
const refresh = await manager.refresh(makeReq('x,y'));
|
||||
expect(refresh.scope).toBe('x y');
|
||||
|
||||
await expect(
|
||||
refresh.commit({
|
||||
session: { scope: 'y,z' },
|
||||
} as OAuthAuthenticatorResult<any>),
|
||||
).resolves.toEqual('y z');
|
||||
});
|
||||
|
||||
it('should include additional scopes', async () => {
|
||||
const manager = CookieScopeManager.create({
|
||||
additionalScopes: ['a', 'b'],
|
||||
...baseOpts,
|
||||
});
|
||||
|
||||
await expect(manager.start(makeReq())).resolves.toEqual({
|
||||
scope: 'a b',
|
||||
});
|
||||
await expect(manager.start(makeReq('x'))).resolves.toEqual({
|
||||
scope: 'x a b',
|
||||
});
|
||||
await expect(manager.start(makeReq('x,y'))).resolves.toEqual({
|
||||
scope: 'x y a b',
|
||||
});
|
||||
|
||||
const refresh = await manager.refresh(makeReq('x|y'));
|
||||
expect(refresh.scope).toBe('x y a b');
|
||||
|
||||
await expect(
|
||||
refresh.commit({
|
||||
session: { scope: 'y,z a' },
|
||||
} as OAuthAuthenticatorResult<any>),
|
||||
).resolves.toEqual('y z a');
|
||||
});
|
||||
|
||||
it('should include additional scopes from config', async () => {
|
||||
await expect(
|
||||
CookieScopeManager.create({
|
||||
additionalScopes: ['a'],
|
||||
...baseOpts,
|
||||
}).start(makeReq()),
|
||||
).resolves.toEqual({ scope: 'a' });
|
||||
|
||||
await expect(
|
||||
CookieScopeManager.create({
|
||||
config: new ConfigReader({ additionalScopes: 'a,b' }),
|
||||
additionalScopes: ['c'],
|
||||
...baseOpts,
|
||||
}).start(makeReq()),
|
||||
).resolves.toEqual({ scope: 'a b c' });
|
||||
|
||||
await expect(
|
||||
CookieScopeManager.create({
|
||||
config: new ConfigReader({ additionalScopes: ['a', 'b'] }),
|
||||
additionalScopes: ['c'],
|
||||
...baseOpts,
|
||||
}).start(makeReq()),
|
||||
).resolves.toEqual({ scope: 'a b c' });
|
||||
});
|
||||
|
||||
it('should persist scopes', async () => {
|
||||
const setGrantedScopes = jest.fn();
|
||||
const removeGrantedScopes = jest.fn();
|
||||
const manager = CookieScopeManager.create({
|
||||
authenticator: {
|
||||
scopes: {
|
||||
persist: true,
|
||||
} as OAuthAuthenticatorScopeOptions,
|
||||
} as OAuthAuthenticator<any, any>,
|
||||
cookieManager: {
|
||||
getGrantedScopes: () => 'g',
|
||||
setGrantedScopes,
|
||||
removeGrantedScopes,
|
||||
} as unknown as OAuthCookieManager,
|
||||
});
|
||||
|
||||
await expect(manager.start(makeReq())).resolves.toEqual({
|
||||
scope: 'g',
|
||||
scopeState: {
|
||||
scope: 'g',
|
||||
},
|
||||
});
|
||||
await expect(manager.start(makeReq('x'))).resolves.toEqual({
|
||||
scope: 'x g',
|
||||
scopeState: {
|
||||
scope: 'x g',
|
||||
},
|
||||
});
|
||||
|
||||
expect(setGrantedScopes).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
manager.handleCallback(makeReq(), {
|
||||
// The state is prioritized even if scope is present in the result
|
||||
result: { session: { scope: 'x,y' } } as OAuthAuthenticatorResult<any>,
|
||||
state: { scope: 'x g' } as OAuthState,
|
||||
origin: 'https://other.example.com',
|
||||
}),
|
||||
).resolves.toEqual('x g');
|
||||
expect(setGrantedScopes).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'x g',
|
||||
'https://other.example.com',
|
||||
);
|
||||
setGrantedScopes.mockClear();
|
||||
|
||||
expect(removeGrantedScopes).not.toHaveBeenCalled();
|
||||
await expect(manager.clear(makeReq())).resolves.toBe(undefined);
|
||||
expect(removeGrantedScopes).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'https://example.com',
|
||||
);
|
||||
|
||||
const refresh = await manager.refresh(makeReq('x,y'));
|
||||
expect(refresh.scope).toBe('x y g');
|
||||
|
||||
expect(setGrantedScopes).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
refresh.commit({
|
||||
session: { scope: 'y,z a' },
|
||||
} as OAuthAuthenticatorResult<any>),
|
||||
).resolves.toEqual('x y g');
|
||||
expect(setGrantedScopes).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'x y g',
|
||||
'https://example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom scope transform', async () => {
|
||||
const manager = CookieScopeManager.create({
|
||||
additionalScopes: ['b'],
|
||||
authenticator: {
|
||||
scopes: {
|
||||
persist: true,
|
||||
required: ['a'],
|
||||
transform: ({ required, additional, requested, granted }) =>
|
||||
new Set([
|
||||
...[...requested].map(s => `requested-${s}`),
|
||||
...[...granted].map(s => `granted-${s}`),
|
||||
...[...required].map(s => `required-${s}`),
|
||||
...[...additional].map(s => `additional-${s}`),
|
||||
]),
|
||||
} as OAuthAuthenticatorScopeOptions,
|
||||
} as OAuthAuthenticator<any, any>,
|
||||
cookieManager: {
|
||||
getGrantedScopes: _req => 'g',
|
||||
setGrantedScopes: (_req, _scope, _origin) => {},
|
||||
} as OAuthCookieManager,
|
||||
});
|
||||
|
||||
await expect(manager.start(makeReq())).resolves.toEqual({
|
||||
scope: 'granted-g required-a additional-b',
|
||||
scopeState: {
|
||||
scope: 'granted-g required-a additional-b',
|
||||
},
|
||||
});
|
||||
await expect(manager.start(makeReq('x'))).resolves.toEqual({
|
||||
scope: 'requested-x granted-g required-a additional-b',
|
||||
scopeState: {
|
||||
scope: 'requested-x granted-g required-a additional-b',
|
||||
},
|
||||
});
|
||||
|
||||
const refresh = await manager.refresh(makeReq('x,y'));
|
||||
expect(refresh.scope).toBe(
|
||||
'requested-x requested-y granted-g required-a additional-b',
|
||||
);
|
||||
|
||||
await expect(
|
||||
refresh.commit({
|
||||
session: { scope: 'y,z a' },
|
||||
} as OAuthAuthenticatorResult<any>),
|
||||
).resolves.toEqual(
|
||||
'requested-x requested-y granted-g required-a additional-b',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail on invalid input', async () => {
|
||||
const manager = CookieScopeManager.create({
|
||||
authenticator: {
|
||||
scopes: {
|
||||
persist: true,
|
||||
} as OAuthAuthenticatorScopeOptions,
|
||||
} as OAuthAuthenticator<any, any>,
|
||||
cookieManager: {} as OAuthCookieManager,
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.handleCallback(makeReq(), {
|
||||
result: { session: { scope: 'x,y' } } as OAuthAuthenticatorResult<any>,
|
||||
state: {} as OAuthState,
|
||||
}),
|
||||
).rejects.toThrow('No scope found in OAuth state');
|
||||
|
||||
await expect(manager.clear({} as express.Request)).rejects.toThrow(
|
||||
'No response object found in request',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright 2024 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 {
|
||||
OAuthAuthenticator,
|
||||
OAuthAuthenticatorResult,
|
||||
OAuthAuthenticatorScopeOptions,
|
||||
} from './types';
|
||||
import { OAuthCookieManager } from './OAuthCookieManager';
|
||||
import { OAuthState } from './state';
|
||||
import { AuthenticationError } from '@backstage/errors';
|
||||
import { Config } from '@backstage/config';
|
||||
|
||||
function reqRes(req: express.Request): express.Response {
|
||||
const res = req.res;
|
||||
if (!res) {
|
||||
throw new Error('No response object found in request');
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
const defaultTransform: Required<OAuthAuthenticatorScopeOptions>['transform'] =
|
||||
({ requested, granted, required, additional }) => {
|
||||
return [...requested, ...granted, ...required, ...additional];
|
||||
};
|
||||
|
||||
function splitScope(scope?: string): Iterable<string> {
|
||||
if (!scope) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return scope.split(/[\s|,]/).filter(Boolean);
|
||||
}
|
||||
|
||||
export class CookieScopeManager {
|
||||
static create(options: {
|
||||
config?: Config;
|
||||
additionalScopes?: string[];
|
||||
authenticator: OAuthAuthenticator<any, any>;
|
||||
cookieManager: OAuthCookieManager;
|
||||
}) {
|
||||
const { authenticator, config } = options;
|
||||
|
||||
const shouldPersistScopes =
|
||||
authenticator.scopes?.persist ??
|
||||
authenticator.shouldPersistScopes ??
|
||||
false;
|
||||
|
||||
const configScopes =
|
||||
typeof config?.getOptional('additionalScopes') === 'string'
|
||||
? splitScope(config.getString('additionalScopes'))
|
||||
: config?.getOptionalStringArray('additionalScopes') ?? [];
|
||||
|
||||
const transform = authenticator?.scopes?.transform ?? defaultTransform;
|
||||
const additional = [...configScopes, ...(options.additionalScopes ?? [])];
|
||||
const required = authenticator?.scopes?.required ?? [];
|
||||
|
||||
return new CookieScopeManager(
|
||||
(requested, granted) =>
|
||||
Array.from(
|
||||
new Set(transform({ required, additional, requested, granted })),
|
||||
).join(' '),
|
||||
shouldPersistScopes ? options.cookieManager : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly scopeTransform: (
|
||||
requested: Iterable<string>,
|
||||
granted: Iterable<string>,
|
||||
) => string,
|
||||
private readonly cookieManager?: OAuthCookieManager,
|
||||
) {}
|
||||
|
||||
async start(
|
||||
req: express.Request,
|
||||
): Promise<{ scopeState?: Partial<OAuthState>; scope: string }> {
|
||||
const requestScope = splitScope(req.query.scope?.toString());
|
||||
const grantedScope = splitScope(this.cookieManager?.getGrantedScopes(req));
|
||||
|
||||
const scope = this.scopeTransform(requestScope, grantedScope);
|
||||
|
||||
if (this.cookieManager) {
|
||||
// If scopes are persisted then we pass them through the state so that we
|
||||
// can set the cookie on successful auth
|
||||
return {
|
||||
scope,
|
||||
scopeState: { scope },
|
||||
};
|
||||
}
|
||||
return { scope };
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
req: express.Request,
|
||||
ctx: {
|
||||
result: OAuthAuthenticatorResult<any>;
|
||||
state: OAuthState;
|
||||
origin?: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
// If we are not persisting scopes we can forward the scope from the result
|
||||
if (!this.cookieManager) {
|
||||
return Array.from(splitScope(ctx.result.session.scope)).join(' ');
|
||||
}
|
||||
|
||||
const scope = ctx.state.scope;
|
||||
if (scope === undefined) {
|
||||
throw new AuthenticationError('No scope found in OAuth state');
|
||||
}
|
||||
|
||||
// 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.
|
||||
this.cookieManager.setGrantedScopes(reqRes(req), scope, ctx.origin);
|
||||
|
||||
return scope;
|
||||
}
|
||||
|
||||
async clear(req: express.Request): Promise<void> {
|
||||
if (this.cookieManager) {
|
||||
this.cookieManager.removeGrantedScopes(reqRes(req), req.get('origin'));
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(req: express.Request): Promise<{
|
||||
scope: string;
|
||||
commit(result: OAuthAuthenticatorResult<any>): Promise<string>;
|
||||
}> {
|
||||
const requestScope = splitScope(req.query.scope?.toString());
|
||||
const grantedScope = splitScope(this.cookieManager?.getGrantedScopes(req));
|
||||
|
||||
const scope = this.scopeTransform(requestScope, grantedScope);
|
||||
|
||||
return {
|
||||
scope,
|
||||
commit: async result => {
|
||||
if (this.cookieManager) {
|
||||
this.cookieManager.setGrantedScopes(
|
||||
reqRes(req),
|
||||
scope,
|
||||
req.get('origin'),
|
||||
);
|
||||
return scope;
|
||||
}
|
||||
|
||||
return Array.from(splitScope(result.session.scope)).join(' ');
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,13 @@ export class OAuthCookieManager {
|
||||
});
|
||||
}
|
||||
|
||||
removeGrantedScopes(res: Response, origin?: string) {
|
||||
res.cookie(this.grantedScopeCookie, '', {
|
||||
maxAge: 0,
|
||||
...this.getConfig(origin),
|
||||
});
|
||||
}
|
||||
|
||||
setGrantedScopes(res: Response, scope: string, origin?: string) {
|
||||
res.cookie(this.grantedScopeCookie, scope, {
|
||||
maxAge: THOUSAND_DAYS_MS,
|
||||
@@ -113,15 +120,15 @@ export class OAuthCookieManager {
|
||||
});
|
||||
}
|
||||
|
||||
getNonce(req: Request) {
|
||||
getNonce(req: Request): string | undefined {
|
||||
return req.cookies[this.nonceCookie];
|
||||
}
|
||||
|
||||
getRefreshToken(req: Request) {
|
||||
getRefreshToken(req: Request): string | undefined {
|
||||
return req.cookies[this.refreshTokenCookie];
|
||||
}
|
||||
|
||||
getGrantedScopes(req: Request) {
|
||||
getGrantedScopes(req: Request): string | undefined {
|
||||
return req.cookies[this.grantedScopeCookie];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { SignInResolverFactory } from '../sign-in/createSignInResolverFactory';
|
||||
/** @public */
|
||||
export function createOAuthProviderFactory<TProfile>(options: {
|
||||
authenticator: OAuthAuthenticator<unknown, TProfile>;
|
||||
additionalScopes?: string[];
|
||||
stateTransform?: OAuthStateTransform;
|
||||
profileTransform?: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
|
||||
signInResolver?: SignInResolver<OAuthAuthenticatorResult<TProfile>>;
|
||||
@@ -57,6 +58,7 @@ export function createOAuthProviderFactory<TProfile>(options: {
|
||||
cookieConfigurer: ctx.cookieConfigurer,
|
||||
providerId: ctx.providerId,
|
||||
resolverContext: ctx.resolverContext,
|
||||
additionalScopes: options.additionalScopes,
|
||||
stateTransform: options.stateTransform,
|
||||
profileTransform: options.profileTransform,
|
||||
signInResolver,
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
encodeOAuthState,
|
||||
decodeOAuthState,
|
||||
OAuthStateTransform,
|
||||
OAuthState,
|
||||
} from './state';
|
||||
import { sendWebMessageResponse } from '../flow';
|
||||
import { prepareBackstageIdentityResponse } from '../identity';
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
} from '../types';
|
||||
import { OAuthAuthenticator, OAuthAuthenticatorResult } from './types';
|
||||
import { Config } from '@backstage/config';
|
||||
import { CookieScopeManager } from './CookieScopeManager';
|
||||
|
||||
/** @public */
|
||||
export interface OAuthRouteHandlersOptions<TProfile> {
|
||||
@@ -52,6 +52,7 @@ export interface OAuthRouteHandlersOptions<TProfile> {
|
||||
providerId: string;
|
||||
config: Config;
|
||||
resolverContext: AuthResolverContext;
|
||||
additionalScopes?: string[];
|
||||
stateTransform?: OAuthStateTransform;
|
||||
profileTransform?: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
|
||||
cookieConfigurer?: CookieConfigurer;
|
||||
@@ -111,14 +112,19 @@ export function createOAuthRouteHandlers<TProfile>(
|
||||
cookieConfigurer,
|
||||
});
|
||||
|
||||
const scopeManager = CookieScopeManager.create({
|
||||
config,
|
||||
authenticator,
|
||||
cookieManager,
|
||||
additionalScopes: options.additionalScopes,
|
||||
});
|
||||
|
||||
return {
|
||||
async start(
|
||||
this: never,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<void> {
|
||||
// retrieve scopes from request
|
||||
const scope = req.query.scope?.toString() ?? '';
|
||||
const env = req.query.env?.toString();
|
||||
const origin = req.query.origin?.toString();
|
||||
const redirectUrl = req.query.redirectUrl?.toString();
|
||||
@@ -132,19 +138,17 @@ export function createOAuthRouteHandlers<TProfile>(
|
||||
// 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 && scope) {
|
||||
state.scope = scope;
|
||||
}
|
||||
const { scope, scopeState } = await scopeManager.start(req);
|
||||
|
||||
const state = { nonce, env, origin, redirectUrl, flow, ...scopeState };
|
||||
const { state: transformedState } = await stateTransform(state, { req });
|
||||
const encodedState = encodeOAuthState(transformedState);
|
||||
|
||||
const { url, status } = await options.authenticator.start(
|
||||
{ req, scope, state: encodedState },
|
||||
{
|
||||
req,
|
||||
scope,
|
||||
state: encodeOAuthState(transformedState),
|
||||
},
|
||||
authenticatorCtx,
|
||||
);
|
||||
|
||||
@@ -159,19 +163,19 @@ export function createOAuthRouteHandlers<TProfile>(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<void> {
|
||||
let appOrigin = defaultAppOrigin;
|
||||
let origin = defaultAppOrigin;
|
||||
|
||||
try {
|
||||
const state = decodeOAuthState(req.query.state?.toString() ?? '');
|
||||
|
||||
if (state.origin) {
|
||||
try {
|
||||
appOrigin = new URL(state.origin).origin;
|
||||
origin = 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`);
|
||||
if (!isOriginAllowed(origin)) {
|
||||
throw new NotAllowedError(`Origin '${origin}' is not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,38 +195,35 @@ export function createOAuthRouteHandlers<TProfile>(
|
||||
);
|
||||
const { profile } = await profileTransform(result, resolverContext);
|
||||
|
||||
const signInResult =
|
||||
signInResolver &&
|
||||
(await signInResolver({ profile, result }, resolverContext));
|
||||
|
||||
const grantedScopes = await scopeManager.handleCallback(req, {
|
||||
result,
|
||||
state,
|
||||
origin,
|
||||
});
|
||||
|
||||
const response: ClientOAuthResponse = {
|
||||
profile,
|
||||
providerInfo: {
|
||||
idToken: result.session.idToken,
|
||||
accessToken: result.session.accessToken,
|
||||
scope: result.session.scope,
|
||||
scope: grantedScopes,
|
||||
expiresInSeconds: result.session.expiresInSeconds,
|
||||
},
|
||||
...(signInResult && {
|
||||
backstageIdentity: prepareBackstageIdentityResponse(signInResult),
|
||||
}),
|
||||
};
|
||||
|
||||
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);
|
||||
response.providerInfo.scope = state.scope;
|
||||
}
|
||||
|
||||
if (result.session.refreshToken) {
|
||||
// set new refresh token
|
||||
cookieManager.setRefreshToken(
|
||||
res,
|
||||
result.session.refreshToken,
|
||||
appOrigin,
|
||||
origin,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -239,7 +240,7 @@ export function createOAuthRouteHandlers<TProfile>(
|
||||
}
|
||||
|
||||
// post message back to popup if successful
|
||||
sendWebMessageResponse(res, appOrigin, {
|
||||
sendWebMessageResponse(res, origin, {
|
||||
type: 'authorization_response',
|
||||
response,
|
||||
});
|
||||
@@ -248,7 +249,7 @@ export function createOAuthRouteHandlers<TProfile>(
|
||||
? error
|
||||
: new Error('Encountered invalid error'); // Being a bit safe and not forwarding the bad value
|
||||
// post error message back to popup if failure
|
||||
sendWebMessageResponse(res, appOrigin, {
|
||||
sendWebMessageResponse(res, origin, {
|
||||
type: 'authorization_response',
|
||||
error: { name, message },
|
||||
});
|
||||
@@ -273,6 +274,9 @@ export function createOAuthRouteHandlers<TProfile>(
|
||||
// remove refresh token cookie if it is set
|
||||
cookieManager.removeRefreshToken(res, req.get('origin'));
|
||||
|
||||
// remove persisted scopes
|
||||
await scopeManager.clear(req);
|
||||
|
||||
res.status(200).end();
|
||||
},
|
||||
|
||||
@@ -294,16 +298,15 @@ export function createOAuthRouteHandlers<TProfile>(
|
||||
throw new InputError('Missing session cookie');
|
||||
}
|
||||
|
||||
let scope = req.query.scope?.toString() ?? '';
|
||||
if (authenticator.shouldPersistScopes) {
|
||||
scope = cookieManager.getGrantedScopes(req);
|
||||
}
|
||||
const scopeRefresh = await scopeManager.refresh(req);
|
||||
|
||||
const result = await authenticator.refresh(
|
||||
{ req, scope, refreshToken },
|
||||
{ req, scope: scopeRefresh.scope, refreshToken },
|
||||
authenticatorCtx,
|
||||
);
|
||||
|
||||
const grantedScope = await scopeRefresh.commit(result);
|
||||
|
||||
const { profile } = await profileTransform(result, resolverContext);
|
||||
|
||||
const newRefreshToken = result.session.refreshToken;
|
||||
@@ -320,9 +323,7 @@ export function createOAuthRouteHandlers<TProfile>(
|
||||
providerInfo: {
|
||||
idToken: result.session.idToken,
|
||||
accessToken: result.session.accessToken,
|
||||
scope: authenticator.shouldPersistScopes
|
||||
? scope
|
||||
: result.session.scope,
|
||||
scope: grantedScope,
|
||||
expiresInSeconds: result.session.expiresInSeconds,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -39,6 +39,7 @@ export {
|
||||
type OAuthAuthenticatorLogoutInput,
|
||||
type OAuthAuthenticatorRefreshInput,
|
||||
type OAuthAuthenticatorResult,
|
||||
type OAuthAuthenticatorScopeOptions,
|
||||
type OAuthAuthenticatorStartInput,
|
||||
type OAuthSession,
|
||||
} from './types';
|
||||
|
||||
@@ -29,6 +29,22 @@ export interface OAuthSession {
|
||||
refreshTokenExpiresInSeconds?: number;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface OAuthAuthenticatorScopeOptions {
|
||||
persist?: boolean;
|
||||
required?: string[];
|
||||
transform?: (options: {
|
||||
/** Scopes requested by the client */
|
||||
requested: Iterable<string>;
|
||||
/** Scopes which have already been granted */
|
||||
granted: Iterable<string>;
|
||||
/** Scopes that are required for the authenticator to function */
|
||||
required: Iterable<string>;
|
||||
/** Additional scopes added through configuration */
|
||||
additional: Iterable<string>;
|
||||
}) => Iterable<string>;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface OAuthAuthenticatorStartInput {
|
||||
scope: string;
|
||||
@@ -64,7 +80,9 @@ export interface OAuthAuthenticatorResult<TProfile> {
|
||||
/** @public */
|
||||
export interface OAuthAuthenticator<TContext, TProfile> {
|
||||
defaultProfileTransform: ProfileTransform<OAuthAuthenticatorResult<TProfile>>;
|
||||
/** @deprecated use `scopes.persist` instead */
|
||||
shouldPersistScopes?: boolean;
|
||||
scopes?: OAuthAuthenticatorScopeOptions;
|
||||
initialize(ctx: { callbackUrl: string; config: Config }): TContext;
|
||||
start(
|
||||
input: OAuthAuthenticatorStartInput,
|
||||
|
||||
Reference in New Issue
Block a user