auth-node: add createOAuthHandlers

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-07-26 13:29:50 +02:00
parent 4f7eed7b25
commit 14fd4fb7c8
@@ -0,0 +1,340 @@
/*
* Copyright 2020 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 crypto from 'crypto';
import { URL } from 'url';
import {
AuthenticationError,
InputError,
isError,
NotAllowedError,
} from '@backstage/errors';
import { defaultStateDecoder, defaultStateEncoder, OAuthState } from './state';
import { postMessageResponse, ensuresXRequestedWith } from '../flow';
import { prepareBackstageIdentityResponse } from '../identity';
import { OAuthCookieManager } from './OAuthCookieManager';
import {
AuthProviderRouteHandlers,
AuthResolverContext,
ClientAuthResponse,
CookieConfigurer,
SignInResolver,
} from '../types';
import {
OAuthAuthenticator,
OAuthAuthenticatorResult,
OAuthProfileTransform,
} from './types';
import { Config } from '@backstage/config';
/** @public */
export interface OAuthHandlersOptions<TProfile> {
authenticator: OAuthAuthenticator<unknown, TProfile>;
appUrl: string;
baseUrl: string;
isOriginAllowed: (origin: string) => boolean;
callbackUrl: string;
providerId: string;
config: Config;
resolverContext: AuthResolverContext;
profileTransform: OAuthProfileTransform<TProfile>;
cookieConfigurer?: CookieConfigurer;
signInResolver?: SignInResolver<OAuthAuthenticatorResult<TProfile>>;
}
/** @internal */
type ClientOAuthResponse = ClientAuthResponse<{
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* (Optional) Id token issued for the signed in user.
*/
idToken?: string;
/**
* Expiry of the access token in seconds.
*/
expiresInSeconds?: number;
/**
* Scopes granted for the access token.
*/
scope: string;
}>;
/** @public */
export function createOAuthHandlers<TProfile>(
options: OAuthHandlersOptions<TProfile>,
): AuthProviderRouteHandlers {
const {
authenticator,
config,
baseUrl,
appUrl,
providerId,
isOriginAllowed,
cookieConfigurer,
resolverContext,
profileTransform,
signInResolver,
} = options;
const defaultAppOrigin = new URL(appUrl).origin;
const callbackUrl =
config.getOptionalString('callbackUrl') ??
`${baseUrl}/${providerId}/handler/frame`;
const authenticatorCtx = authenticator.initialize({ config, callbackUrl });
const cookieManager = new OAuthCookieManager({
baseUrl,
callbackUrl,
defaultAppOrigin,
providerId,
cookieConfigurer,
});
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();
const flow = req.query.flow?.toString();
if (!env) {
throw new InputError('No env provided in request query parameters');
}
const nonce = crypto.randomBytes(16).toString('base64');
// 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) {
state.scope = scope;
}
const { encodedState } = await defaultStateEncoder(state, { req });
const { url, status } = await options.authenticator.start(
{ req, scope, state: encodedState },
authenticatorCtx,
);
res.statusCode = status || 302;
res.setHeader('Location', url);
res.setHeader('Content-Length', '0');
res.end();
},
async frameHandler(
this: never,
req: express.Request,
res: express.Response,
): Promise<void> {
let appOrigin = defaultAppOrigin;
try {
const { state } = await defaultStateDecoder(
req.query.state?.toString() ?? '',
{ req },
);
if (state.origin) {
try {
appOrigin = 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`);
}
}
// The same nonce is passed through cookie and state, and they must match
const cookieNonce = cookieManager.getNonce(req);
const stateNonce = state.nonce;
if (!cookieNonce) {
throw new Error('Auth response is missing cookie nonce');
}
if (stateNonce.length === 0) {
throw new Error('Auth response is missing state nonce');
}
if (cookieNonce !== stateNonce) {
throw new Error('Invalid nonce');
}
const result = await authenticator.authenticate(
{ req },
authenticatorCtx,
);
const { profile } = await profileTransform(result, resolverContext);
const response: ClientOAuthResponse = {
profile,
providerInfo: {
idToken: result.session.idToken,
accessToken: result.session.accessToken,
scope: result.session.scope,
expiresInSeconds: result.session.expiresInSeconds,
},
};
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);
result.session.scope = state.scope;
}
if (result.session.refreshToken) {
// set new refresh token
cookieManager.setRefreshToken(
res,
result.session.refreshToken,
appOrigin,
);
}
// When using the redirect flow we rely on refresh token we just
// acquired to get a new session once we're back in the app.
if (state.flow === 'redirect') {
if (!state.redirectUrl) {
throw new InputError(
'No redirectUrl provided in request query parameters',
);
}
res.redirect(state.redirectUrl);
}
// post message back to popup if successful
return postMessageResponse(res, appOrigin, {
type: 'authorization_response',
response,
});
} catch (error) {
const { name, message } = isError(error)
? error
: new Error('Encountered invalid error'); // Being a bit safe and not forwarding the bad value
// post error message back to popup if failure
return postMessageResponse(res, appOrigin, {
type: 'authorization_response',
error: { name, message },
});
}
},
async logout(
this: never,
req: express.Request,
res: express.Response,
): Promise<void> {
if (!ensuresXRequestedWith(req)) {
throw new AuthenticationError('Invalid X-Requested-With header');
}
if (authenticator.logout) {
const refreshToken = cookieManager.getRefreshToken(req);
await authenticator.logout({ req, refreshToken }, authenticatorCtx);
}
// remove refresh token cookie if it is set
cookieManager.removeRefreshToken(res, req.get('origin'));
res.status(200).end();
},
async refresh(
this: never,
req: express.Request,
res: express.Response,
): Promise<void> {
if (!ensuresXRequestedWith(req)) {
throw new AuthenticationError('Invalid X-Requested-With header');
}
try {
const refreshToken = cookieManager.getRefreshToken(req);
// throw error if refresh token is missing in the request
if (!refreshToken) {
throw new InputError('Missing session cookie');
}
let scope = req.query.scope?.toString() ?? '';
if (authenticator.shouldPersistScopes) {
scope = cookieManager.getGrantedScopes(req);
}
const result = await authenticator.refresh(
{ req, scope, refreshToken },
authenticatorCtx,
);
const { profile } = await profileTransform(result, resolverContext);
const newRefreshToken = result.session.refreshToken;
if (newRefreshToken && newRefreshToken !== refreshToken) {
cookieManager.setRefreshToken(
res,
newRefreshToken,
req.get('origin'),
);
}
const response: ClientOAuthResponse = {
profile,
providerInfo: {
idToken: result.session.idToken,
accessToken: result.session.accessToken,
scope: result.session.scope,
expiresInSeconds: result.session.expiresInSeconds,
},
};
if (signInResolver) {
const identity = await signInResolver(
{ profile, result },
resolverContext,
);
response.backstageIdentity =
prepareBackstageIdentityResponse(identity);
}
res.status(200).json(response);
} catch (error) {
throw new AuthenticationError('Refresh failed', error);
}
},
};
}