auth-node: initial scope manager refactor
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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