Skip to content

Commit

Permalink
fix: shorten auth state (#493)
Browse files Browse the repository at this point in the history
  • Loading branch information
tripodsan authored Jan 15, 2024
1 parent 596f20d commit 72b3ede
Show file tree
Hide file tree
Showing 9 changed files with 248 additions and 160 deletions.
1 change: 1 addition & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: Build
on:
push:
branches-ignore:
- 5.x
- main

jobs:
Expand Down
5 changes: 5 additions & 0 deletions src/PipelineState.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,5 +141,10 @@ declare class PipelineState {
* the custom live host if configured via config.cdn.live.host
*/
liveHost: string;

/**
* used for development server to include RSO information in the auth state
*/
authIncludeRSO: boolean;
}

57 changes: 57 additions & 0 deletions src/auth-pipe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright 2021 Adobe. All rights reserved.
* This file is licensed to you 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 REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import { cleanupHeaderValue } from '@adobe/helix-shared-utils';
import setCustomResponseHeaders from './steps/set-custom-response-headers.js';
import { PipelineResponse } from './PipelineResponse.js';
import { validateAuthState, getAuthInfo } from './utils/auth.js';

/**
* Runs the auth pipeline that handles the token exchange. this is separated from the main pipeline
* since it doesn't need the configuration.
*
* @param {PipelineState} state
* @param {PipelineRequest} req
* @returns {PipelineResponse}
*/
export async function authPipe(state, req) {
const { log } = state;

/** @type PipelineResponse */
const res = new PipelineResponse('', {
headers: {
'content-type': 'text/html; charset=utf-8',
},
});

try {
await validateAuthState(state, req);
const authInfo = await getAuthInfo(state, req);
await authInfo.exchangeToken(state, req, res);
/* c8 ignore next */
const level = res.status >= 500 ? 'error' : 'info';
log[level](`pipeline status: ${res.status} ${res.error}`);
res.headers.set('x-error', cleanupHeaderValue(res.error));
if (res.status < 500) {
await setCustomResponseHeaders(state, req, res);
}
return res;
} catch (e) {
return new PipelineResponse('', {
status: 401,
headers: {
'cache-control': 'no-store, private, must-revalidate',
'content-type': 'text/html; charset=utf-8',
'x-error': e.message,
},
});
}
}
15 changes: 0 additions & 15 deletions src/html-pipe.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import tohtml from './steps/stringify-response.js';
import { PipelineStatusError } from './PipelineStatusError.js';
import { PipelineResponse } from './PipelineResponse.js';
import { validatePathInfo } from './utils/path.js';
import { getAuthInfo } from './utils/auth.js';
import fetchMappedMetadata from './steps/fetch-mapped-metadata.js';

/**
Expand Down Expand Up @@ -104,20 +103,6 @@ export async function htmlPipe(state, req) {
},
});

// check if `.auth` route to validate and exchange token
if (state.partition === '.auth' || state.info.path === '/.auth') {
const authInfo = await getAuthInfo(state, req);
await authInfo.exchangeToken(state, req, res);
/* c8 ignore next */
const level = res.status >= 500 ? 'error' : 'info';
log[level](`pipeline status: ${res.status} ${res.error}`);
res.headers.set('x-error', cleanupHeaderValue(res.error));
if (res.status < 500) {
await setCustomResponseHeaders(state, req, res);
}
return res;
}

try {
await initConfig(state, req, res);

Expand Down
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
*/
export * from './html-pipe.js';
export * from './json-pipe.js';
export * from './auth-pipe.js';
export * from './options-pipe.js';
export * from './PipelineContent.js';
export * from './PipelineRequest.js';
export * from './PipelineResponse.js';
export * from './PipelineState.js';
export * from './PipelineStatusError.js';
export { validateAuthState } from './utils/auth.js';
106 changes: 76 additions & 30 deletions src/utils/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,24 @@ let ADMIN_KEY_PAIR = null;
export class AccessDeniedError extends Error {
}

async function getAdminKeyPair(state) {
if (!ADMIN_KEY_PAIR) {
ADMIN_KEY_PAIR = {
privateKey: await importJWK(JSON.parse(state.env.HLX_ADMIN_IDP_PRIVATE_KEY), 'RS256'),
publicKey: JSON.parse(state.env.HLX_ADMIN_IDP_PUBLIC_KEY),
};
}
return ADMIN_KEY_PAIR;
}

/**
* Signs the given JWT with the admin private key and returns the token.
* @param {PipelineState} state
* @param {SignJWT} jwt
* @returns {Promise<string>}
*/
async function signJWT(state, jwt) {
if (!ADMIN_KEY_PAIR) {
ADMIN_KEY_PAIR = {
privateKey: await importJWK(JSON.parse(state.env.HLX_ADMIN_IDP_PRIVATE_KEY), 'RS256'),
publicKey: JSON.parse(state.env.HLX_ADMIN_IDP_PUBLIC_KEY),
};
}
const { privateKey, publicKey } = ADMIN_KEY_PAIR;
const { privateKey, publicKey } = await getAdminKeyPair(state);
return jwt
.setProtectedHeader({
alg: 'RS256',
Expand All @@ -55,6 +59,23 @@ async function signJWT(state, jwt) {
.sign(privateKey);
}

/**
* Creates the auth state JWT for redirecting back to the initial page
* @param {PipelineState} state
* @param {SignJWT} jwt
* @returns {Promise<string>}
*/
async function createStateJWT(state, jwt) {
const { privateKey, publicKey } = await getAdminKeyPair(state);
return jwt
.setProtectedHeader({
alg: 'RS256',
kid: publicKey.kid,
})
.setIssuer(publicKey.issuer)
.sign(privateKey);
}

/**
* Verifies and decodes the given jwt using the admin public key
* @param {PipelineState} state
Expand All @@ -75,6 +96,23 @@ async function verifyJwt(state, jwt, lenient = false) {
return payload;
}

/**
* Verifies and decodes the given state jwt using the admin public key
* @param {PipelineState} state
* @param {string} jwt
* @returns {Promise<JWTPayload>}
*/
async function verifyStateJwt(state, jwt) {
const publicKey = JSON.parse(state.env.HLX_ADMIN_IDP_PUBLIC_KEY);
const jwks = createLocalJWKSet({
keys: [publicKey],
});
const { payload } = await jwtVerify(jwt, jwks, {
issuer: publicKey.issuer,
});
return payload;
}

/**
* Decodes the given id_token for the given idp. if `lenient` is `true`, the clock tolerance
* is set to 1 week. this allows to extract some profile information that can be used as login_hint.
Expand Down Expand Up @@ -228,26 +266,29 @@ export class AuthInfo {
}

// determine the location of 'this' document based on the xfh header. so that logins to
// .page stay on .page. etc. but fallback to the config.host if non set
// .page stay on .page. etc. but fallback to the production host if not set
const { host, proto } = getRequestHostAndProto(state, req);
if (!host) {
log.error('[auth] unable to create login redirect: no xfh or config.host.');
makeAuthError(state, req, res, 'no host information.');
return;
}

const url = new URL(idp.discovery.authorization_endpoint);

const tokenState = await signJWT(state, new SignJWT({
ref: state.ref,
org: state.org,
site: state.site,
// this is our own login redirect, i.e. the current document
requestPath: state.info.path,
requestHost: host,
requestProto: proto,
}));
// create the token state, so stat we know where to redirect back after the token exchange
const payload = {
url: `${proto}://${host}${state.info.path}`,
};
// this is for the development server to remember the org, site, ref and partition
// normally, this is not needed as the host is used to determine that information
if (state.authIncludeRSO) {
payload.org = state.org;
payload.site = state.site;
payload.ref = state.ref;
payload.partition = state.partition;
}
const tokenState = await createStateJWT(state, new SignJWT(payload));

const url = new URL(idp.discovery.authorization_endpoint);
url.searchParams.append('client_id', clientId);
url.searchParams.append('response_type', 'code');
url.searchParams.append('scope', idp.scope);
Expand Down Expand Up @@ -370,8 +411,8 @@ export class AuthInfo {
}
}

export async function validateAuthState(ctx, req) {
const { log } = ctx;
export async function validateAuthState(state, req) {
const { log } = state;
// use request headers if present
if (req.headers.get('x-hlx-auth-state')) {
log.info('[auth] override params.state from header.');
Expand All @@ -389,19 +430,24 @@ export async function validateAuthState(ctx, req) {

try {
req.params.rawState = req.params.state;
req.params.state = await verifyJwt(ctx, req.params.state);
delete req.params.state.aud;
delete req.params.state.iss;
const payload = await verifyStateJwt(state, req.params.state);
const url = new URL(payload.url);
req.params.state = {
requestPath: url.pathname,
requestHost: url.host,
requestProto: url.protocol.replace(/:$/, ''),
};
// for development server
if (payload.org && payload.site && payload.ref && payload.partition) {
state.org = payload.org;
state.site = payload.site;
state.ref = payload.ref;
state.partition = payload.partition;
}
} catch (e) {
log.warn(`[auth] error decoding state parameter: invalid state: ${e.message}`);
throw new Error('invalid state parameter.');
}

return {
ref: req.params.state.ref,
site: req.params.state.site,
org: req.params.state.org,
};
}

/**
Expand Down
87 changes: 87 additions & 0 deletions test/auth-pipe.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2021 Adobe. All rights reserved.
* This file is licensed to you 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 REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

/* eslint-env mocha */
import assert from 'assert';
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
import { FileS3Loader } from './FileS3Loader.js';
import {
authPipe, PipelineRequest, PipelineState,
} from '../src/index.js';

const DEFAULT_CONFIG = {
contentBusId: 'foo-id',
owner: 'adobe',
repo: 'helix-pages',
ref: 'main',
};

const DEFAULT_STATE = (config = DEFAULT_CONFIG, opts = {}) => (new PipelineState({
config,
site: 'site',
org: 'org',
ref: 'ref',
partition: 'preview',
s3Loader: new FileS3Loader(),
...opts,
}));

describe('Auth Pipe Test', () => {
it('handles /.auth route', async () => {
const keyPair = await generateKeyPair('RS256');
const { privateKey, publicKey } = keyPair;
const env = {
HLX_ADMIN_IDP_PUBLIC_KEY: JSON.stringify({
...await exportJWK(publicKey),
kid: 'dummy-kid',
}),
HLX_ADMIN_IDP_PRIVATE_KEY: JSON.stringify(await exportJWK(privateKey)),
HLX_SITE_APP_AZURE_CLIENT_ID: 'dummy-clientid',
};

const tokenState = await new SignJWT({
url: 'https://www.hlx.live/en',
})
.setProtectedHeader({ alg: 'RS256', kid: 'dummy-kid' })
.setIssuer('urn:example:issuer')
.setAudience('dummy-clientid')
.sign(privateKey);

const state = DEFAULT_STATE(DEFAULT_CONFIG, {
env,
path: '/.auth',
});
const req = new PipelineRequest('https://localhost/.auth', {
headers: new Map(Object.entries({
'x-hlx-auth-state': tokenState,
'x-hlx-auth-code': '1234-code',
})),
});
const resp = await authPipe(state, req);
assert.strictEqual(resp.status, 302);
assert.strictEqual(resp.headers.get('location'), `https://www.hlx.live/.auth?state=${tokenState}&code=1234-code`);
});

it('handles error in the /.auth route', async () => {
const state = DEFAULT_STATE(DEFAULT_CONFIG, {
path: '/.auth',
});
const req = new PipelineRequest('https://localhost/.auth', {
headers: new Map(Object.entries({
'x-hlx-auth-state': 'invalid',
'x-hlx-auth-code': '1234-code',
})),
});
const resp = await authPipe(state, req);
assert.strictEqual(resp.status, 401);
});
});
Loading

0 comments on commit 72b3ede

Please sign in to comment.