auth-node: initial scope manager refactor

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-05-12 22:22:33 +02:00
parent 582bedd380
commit 798ec37c1c
8 changed files with 267 additions and 47 deletions
+13
View File
@@ -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.
+20
View File
@@ -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,156 @@
/*
* 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';
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'] =
({ required, additional, requested, granted }) => {
return new Set([...required, ...requested, ...additional, ...granted]);
};
function splitScope(scope?: string): Iterable<string> {
if (!scope) {
return [];
}
return new Set(scope.split(/[\s|,]/).filter(Boolean));
}
export class CookieScopeManager {
static create(options: {
additionalScopes: string[];
authenticator: OAuthAuthenticator<any, any>;
cookieManager: OAuthCookieManager;
}) {
const { authenticator } = options;
const shouldPersistScopes =
authenticator.scopes?.persist ??
authenticator.shouldPersistScopes ??
false;
const transform = authenticator?.scopes?.transform ?? defaultTransform;
const additional = options.additionalScopes;
const required = authenticator?.scopes?.required ?? [];
return new CookieScopeManager(
(requested, granted) =>
Array.from(
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 scope = this.scopeTransform(requestScope, []);
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 ctx.result.session.scope;
}
const scope = ctx.state.scope;
if (!scope) {
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 result.session.scope;
},
};
}
}
@@ -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,21 @@ export function createOAuthRouteHandlers<TProfile>(
cookieConfigurer,
});
const scopeManager = CookieScopeManager.create({
authenticator,
cookieManager,
additionalScopes: [
...(options.additionalScopes ?? []),
...(config.getOptionalStringArray('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 +140,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 +165,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 +197,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 +242,7 @@ export function createOAuthRouteHandlers<TProfile>(
}
// post message back to popup if successful
sendWebMessageResponse(res, appOrigin, {
sendWebMessageResponse(res, origin, {
type: 'authorization_response',
response,
});
@@ -248,7 +251,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 +276,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 +300,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 +325,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,
},
};
+1
View File
@@ -39,6 +39,7 @@ export {
type OAuthAuthenticatorLogoutInput,
type OAuthAuthenticatorRefreshInput,
type OAuthAuthenticatorResult,
type OAuthAuthenticatorScopeOptions,
type OAuthAuthenticatorStartInput,
type OAuthSession,
} from './types';
+18
View File
@@ -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,