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
+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,