Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve authentication implementation #90

Merged
merged 4 commits into from
Jun 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/PipelineState.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ declare interface PipelineOptions {
path: string;
contentBusId: string;
timer: PipelineTimer;
env: object;
}

declare class PipelineState {
constructor(opts: PipelineOptions);
log: Console;
env: object;
info: PathInfo;
content: PipelineContent;
contentBusId: string;
Expand Down
1 change: 1 addition & 0 deletions src/PipelineState.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export class PipelineState {
constructor(opts) {
Object.assign(this, {
log: opts.log ?? console,
env: opts.env,
info: getPathInfo(opts.path),
content: new PipelineContent(),
// todo: compute content-bus id from fstab
Expand Down
2 changes: 1 addition & 1 deletion src/steps/authenticate.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,6 @@ export async function authenticate(state, req, res) {
res.headers.set('x-hlx-auth-iss', authInfo.profile.iss);
res.headers.set('x-hlx-auth-kid', authInfo.profile.kid);
res.headers.set('x-hlx-auth-aud', authInfo.profile.aud);
res.headers.set('x-hlx-auth-jwk', JSON.stringify(authInfo.profile.jwk));
res.headers.set('x-hlx-auth-key', authInfo.profile.pem);
}
}
61 changes: 44 additions & 17 deletions src/utils/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/
// eslint-disable-next-line max-classes-per-file
import {
createLocalJWKSet, createRemoteJWKSet, decodeJwt, jwtVerify, UnsecuredJWT,
createLocalJWKSet, createRemoteJWKSet, decodeJwt, jwtVerify, UnsecuredJWT, exportSPKI,
} from 'jose';
import { clearAuthCookie, getAuthCookie, setAuthCookie } from './auth-cookie.js';

Expand Down Expand Up @@ -64,16 +64,38 @@ export async function decodeIdToken(state, idp, idToken, lenient = false) {
payload.ttl = payload.exp - Math.floor(Date.now() / 1000);

// export the public key
payload.jwk = key.export({
type: 'pkcs1',
format: 'jwk',
});
payload.pem = await exportSPKI(key);
// and encode it base64 url
payload.pem = Buffer.from(payload.pem, 'utf-8').toString('base64url');
payload.kid = protectedHeader.kid;

log.info(`[auth] decoded id_token${lenient ? ' (lenient)' : ''} from ${payload.iss} and validated payload.`);
return payload;
}

/**
* Returns the host of the request; falls back to the configured `host`.
* Note that this is different from the `config.host` calculation in `fetch-config-all`,
* as this prefers the xfh over the config.
*
* @param {PipelineState} state
* @param {PipelineRequest} req
* @return {string}
*/
function getRequestHost(state, req) {
// 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
let host = req.headers.get('x-forwarded-host');
if (host) {
host = host.split(',')[0].trim();
}
if (!host) {
host = state.config.host;
}
state.log.info(`request host is: ${host}`);
return host;
}

/**
* AuthInfo class
*/
Expand Down Expand Up @@ -160,13 +182,7 @@ 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
let host = req.headers.get('x-forwarded-host');
if (host) {
host = host.split(',')[0].trim();
}
if (!host) {
host = state.config.host;
}
const host = getRequestHost(state, req);
if (!host) {
log.error('[auth] unable to create login redirect: no xfh or config.host.');
res.status = 401;
Expand Down Expand Up @@ -224,7 +240,7 @@ export class AuthInfo {

// ensure that the request is made to the target host
if (req.params.state?.requestHost) {
const host = req.headers.get('x-forwarded-host') || state.config.host;
const host = getRequestHost(state, req);
if (host !== req.params.state.requestHost) {
const url = new URL(`https://${req.params.state.requestHost}/.auth`);
url.searchParams.append('state', req.params.rawState);
Expand Down Expand Up @@ -330,15 +346,25 @@ export function initAuthRoute(state, req, res) {
}

/**
* Extracts the authentication info from the cookie. Returns {@code null} if missing or invalid.
* Extracts the authentication info from the cookie or 'authorization' header.
* Returns {@code null} if missing or invalid.
*
* @param {PipelineState} state
* @param {PipelineRequest} req
* @returns {Promise<AuthInfo>} the authentication info or null if the request is not authenticated
*/
async function getAuthInfoFromCookie(state, req) {
async function getAuthInfoFromCookieOrHeader(state, req) {
const { log } = state;
const idToken = getAuthCookie(req);
let idToken = getAuthCookie(req);
if (!idToken) {
log.info('no auth cookie');
const [marker, value] = (req.headers.get('authorization') || '').split(' ');
if (marker.toLowerCase() === 'token' && value) {
idToken = value.trim();
} else {
log.info('no auth header');
}
}
if (idToken) {
let idp;
try {
Expand Down Expand Up @@ -375,6 +401,7 @@ async function getAuthInfoFromCookie(state, req) {
return AuthInfo.Default().withCookieInvalid(true);
}
}
log.info('no id_token');
return null;
}

Expand All @@ -386,7 +413,7 @@ async function getAuthInfoFromCookie(state, req) {
*/
export async function getAuthInfo(state, req) {
const { log } = state;
const auth = await getAuthInfoFromCookie(state, req);
const auth = await getAuthInfoFromCookieOrHeader(state, req);
if (auth) {
if (auth.authenticated) {
log.info(`[auth] id-token valid: iss=${auth.profile.iss}`);
Expand Down
6 changes: 3 additions & 3 deletions src/utils/idp-configs/microsoft.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
export default {
name: 'microsoft',
mountType: 'onedrive',
client: () => ({
clientId: process.env.HLX_SITE_APP_AZURE_CLIENT_ID,
clientSecret: process.env.HLX_SITE_APP_AZURE_CLIENT_SECRET,
client: (state) => ({
clientId: state.env.HLX_SITE_APP_AZURE_CLIENT_ID,
clientSecret: state.env.HLX_SITE_APP_AZURE_CLIENT_SECRET,
}),
scope: 'openid profile email',
validateIssuer: (iss) => iss.startsWith('https://login.microsoftonline.com/'),
Expand Down
4 changes: 2 additions & 2 deletions test/steps/authenticate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ describe('Authenticate Test', () => {
email: 'test@adobe.com',
aud: 'aud',
iss: 'iss',
jwk: { k: '123', alg: 'RSA' },
pem: '1234',
kid: 'kid',
},
}),
Expand All @@ -123,7 +123,7 @@ describe('Authenticate Test', () => {
'x-hlx-auth-allow': '*@adobe.com',
'x-hlx-auth-aud': 'aud',
'x-hlx-auth-iss': 'iss',
'x-hlx-auth-jwk': '{"k":"123","alg":"RSA"}',
'x-hlx-auth-key': '1234',
'x-hlx-auth-kid': 'kid',
});
});
Expand Down
36 changes: 31 additions & 5 deletions test/utils/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,33 @@ describe('Auth Test', () => {
};
});

it('getAuthInfo returns unauthenticated if no cookie', async () => {
it('getAuthInfo returns unauthenticated if no cookie or header', async () => {
const state = new PipelineState({});
const authInfo = await getAuthInfo(state, {
cookies: {
const req = new PipelineRequest('https://www.hlx.live');
const authInfo = await getAuthInfo(state, req);
assert.strictEqual(authInfo.authenticated, false);
});

it('getAuthInfo rejects invalid auth header token', async () => {
const state = new PipelineState({});
const req = new PipelineRequest('https://www.hlx.live', {
headers: {
authorization: 'Token 1234',
},
});
const authInfo = await getAuthInfo(state, req);
assert.strictEqual(authInfo.cookieInvalid, true);
assert.strictEqual(authInfo.authenticated, false);
});

it('getAuthInfo rejects malformed auth header token', async () => {
const state = new PipelineState({});
const req = new PipelineRequest('https://www.hlx.live', {
headers: {
authorization: 'Token',
},
});
const authInfo = await getAuthInfo(state, req);
assert.strictEqual(authInfo.authenticated, false);
});

Expand Down Expand Up @@ -170,7 +191,7 @@ describe('Auth Test', () => {
assert.strictEqual(authInfo.authenticated, true);
assert.ok(Math.abs(authInfo.profile.ttl - 7200) < 2);
delete authInfo.profile.ttl;
delete authInfo.profile.jwk;
delete authInfo.profile.pem;
assert.deepStrictEqual(authInfo.profile, {
aud: 'dummy-clientid',
email: 'bob',
Expand Down Expand Up @@ -546,7 +567,12 @@ describe('AuthInfo tests', () => {
});

it('exchangeToken handles decode errors', async () => {
const state = new PipelineState({});
const state = new PipelineState({
env: {
HLX_SITE_APP_AZURE_CLIENT_ID: '1234',
HLX_SITE_APP_AZURE_CLIENT_SECRET: 'dummy',
},
});
state.fetch = () => new Response('gobledegook', {
status: 200,
});
Expand Down