Skip to content

Commit

Permalink
feat: add server-side redirects for Google Sign In
Browse files Browse the repository at this point in the history
  • Loading branch information
AJ ONeal committed Jan 2, 2022
1 parent c6d3bb2 commit 600f87d
Show file tree
Hide file tree
Showing 6 changed files with 299 additions and 4 deletions.
5 changes: 4 additions & 1 deletion example/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,10 @@ async function getUserByPassword(req) {
//secret: process.env.HMAC_SECRET || process.env.COOKIE_SECRET,
});
sessionMiddleware.oidc({
"accounts.google.com": { clientId: process.env.GOOGLE_CLIENT_ID },
"accounts.google.com": {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
});
sessionMiddleware.challenge({
notify: notify,
Expand Down
13 changes: 13 additions & 0 deletions lib/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,18 @@ function SUSPICIOUS_TOKEN() {

// Likely Developer Mistakes

/**
* @param {any} [details]
* @returns {AuthError}
*/
function OIDC_BAD_GATEWAY(details) {
return create("remote server gave a non-OK response", {
status: 502,
code: "E_OIDC_BAD_GATEWAY",
details: details,
});
}

/**
* @param {any} [details]
* @returns {AuthError}
Expand Down Expand Up @@ -192,6 +204,7 @@ module.exports = {
SUSPICIOUS_REQUEST,
SUSPICIOUS_TOKEN,
SESSION_INVALID,
OIDC_BAD_GATEWAY,
// Dev
DEVELOPER_ERROR,
WRONG_TOKEN_TYPE,
Expand Down
198 changes: 196 additions & 2 deletions lib/oidc/accounts.google.com/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
"use strict";

var E = require("../../errors.js");
var OIDC = require("../");
var crypto = require("crypto");

// TODO initialize and require from ./lib/request.js
let request = require("@root/request");

function toUrlBase64(val) {
return Buffer.from(val)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}

/**
* @param {OidcMiddlewareOpts} opts
Expand All @@ -12,6 +25,8 @@ function authorize({
grantTokensAndCookie,
verifyOidcToken,
}) {
let issuer = "https://accounts.google.com";

/**
* @param {string} clientId
* @param {OidcVerifyOpts} verifyOpts
Expand All @@ -20,7 +35,7 @@ function authorize({
if (!verifyOpts) {
verifyOpts = {};
}
verifyOpts.iss = "https://accounts.google.com";
verifyOpts.iss = issuer;
return verifyOidcToken(
verifyOpts,
/**
Expand All @@ -37,6 +52,11 @@ function authorize({
);
}

async function fetchOidcConfig(req, res, next) {
req.oidcConfig = await OIDC.getConfig(issuer);
next();
}

let google = opts.oidc["accounts.google.com"] || opts.oidc.google;
let googleVerifierOpts = {};
if (opts.DEVELOPMENT && !opts.__DEVELOPMENT_2) {
Expand Down Expand Up @@ -69,9 +89,183 @@ function authorize({
// TODO deprecate
if (allClaims || !res.headersSent) {
let tokens = await grantTokensAndCookie(allClaims, req, res);
res.json(tokens);
if (!req._oidc_noreply) {
res.json(tokens);
} else {
// TODO return tokens;?
}
}
};

function redirectGoogleSignIn(clientId, loginUrl) {
let trustedUrl = loginUrl || opts.issuer;
if (!trustedUrl) {
throw Error("[auth3000] [google] no issuer / loginUrl given");
}

// ensure the url ends with '/' for the purposes of checking
if ("/" !== trustedUrl[trustedUrl.length - 1]) {
trustedUrl = `${trustedUrl}/`;
}

/**
* @param {String} trusted - the base URL that we trust
* @param {String} untrusted - the redirect URL
* @returns {Boolean}
*/
function prefixesUrl(untrustedUrl) {
if ("/" !== untrustedUrl[untrustedUrl.length - 1]) {
untrustedUrl = `${untrustedUrl}/`;
}

// TODO throw instead of false?
return untrustedUrl.startsWith(trustedUrl);
}

async function redirectToGoogle(req, res) {
//var oidcAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth";
var oidcAuthUrl = req.oidcConfig.authorization_endpoint;
let requestedRedirect = req.headers.referer || trustedUrl;

let isUnsafe = !prefixesUrl(trustedUrl, requestedRedirect);
if (isUnsafe) {
res.statusCode = 500;
res.end(
`invalid redirect URL: '${requestedRedirect}' is not child of '${trustedUrl}'`
);
return;
}

// TODO use base62 crc32
let state = {
u: requestedRedirect,
r: crypto.randomInt(Math.pow(2, 32) - 1).toString(16),
};
state = toUrlBase64(JSON.stringify(state));

let selfUrl = req.originalUrl || req.url;
selfUrl = new URL(`https://example.com${selfUrl}`).pathname;
selfUrl = `${opts.issuer}${selfUrl}`;

let url = OIDC.generateOidcUrl(
oidcAuthUrl,
clientId,
// ex: https://app.example.com/api/authn/session/oidc/accounts.google.com/redirect
selfUrl,
state,
req.query.scope || "email profile",
req.query.login_hint
);

// "Found" (a.k.a. temporary redirect)
res.redirect(302, url);
}

async function complete(req, res) {
let state = JSON.parse(Buffer.from(req.query.state, "base64"));
let finalUrl = state.u;

let isUnsafe = !prefixesUrl(trustedUrl, finalUrl);
if (isUnsafe) {
res.statusCode = 500;
res.end(
`invalid redirect URL: '${finalUrl}' is not child of '${trustedUrl}'`
);
return;
}

// don't pass these to the front end
// TODO remove rather than set undefined
req.query.state = "";
if (req.query.id_token) {
req.query.id_token = "";
}
if (req.query.access_token) {
req.query.access_token = "";
}
// pass what google gave us (such as errors) to the front end
let search = new URLSearchParams(req.query).toString();

if (finalUrl.includes("?")) {
finalUrl = `${finalUrl}&${search}`;
} else {
finalUrl = `${finalUrl}?${search}`;
}

req._oidc_noreply = true;
if (req.headers.authorization) {
await byOidc(req, res);
}

// "Found" (a.k.a. temporary redirect)
res.redirect(302, finalUrl);
}

return async function (req, res) {
// Front End requested Google Sign-In Redirect
if (!req.query.state) {
await redirectToGoogle(req, res);
return;
}

await complete(req, res);
};
}

// This handles two scenarios:
// 1. Front end initiates Google Sign-In OIDC redirect process
// (browser is redirected to google)
// 2. Google responds with token and/or failure
// (browser is redirected back to the initial referrer)
// (TODO: allow client-side redirect_uri)
app.get(
"/session/oidc/accounts.google.com/redirect",
fetchOidcConfig,
async function _upgradeGoogleResponse(req, res, next) {
var oidcTokenUrl = req.oidcConfig.token_endpoint;
// (2) a little monkey patch to switche Google's id_token query param
// to a Bearer, because that's what the existing token verifier expects
if (!req.query.code) {
next();
return;
}

let clientId = google.clientId;
let clientSecret = google.clientSecret;
let code = req.query.code;

let selfUrl = req.originalUrl || req.url;
selfUrl = new URL(`https://example.com${selfUrl}`).pathname;
selfUrl = `${opts.issuer}${selfUrl}`;

let resp = await request({
method: "POST",
url: oidcTokenUrl,
// www-urlencoded...
json: true,
form: {
client_id: clientId,
client_secret: clientSecret,
code: code,
grant_type: "authorization_code",
redirect_uri: selfUrl,
},
});

let id_token = resp.toJSON().body.id_token;

req.headers.authorization = `Bearer ${id_token}`;
next();
},
verifyGoogleToken(
google.clientId,
// (1) Optional because the process starts with a request for an id_token
// (and obviously no id_token can be present yet)
Object.assign({ _optional: true }, googleVerifierOpts)
),
redirectGoogleSignIn(google.clientId, google.loginUrl)
);

app.post(
"/session/oidc/accounts.google.com",
verifyGoogleToken(google.clientId, googleVerifierOpts),
Expand Down
77 changes: 77 additions & 0 deletions lib/oidc/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"use strict";

let crypto = require("crypto");
let OIDC = module.exports;
let Errors = require("../errors.js");

let request = require("@root/request");

OIDC._querystringify = function (params) {
// { foo: 'bar', baz: 'qux' } => foo=bar&baz=qux
return new URLSearchParams(params).toString();
};

OIDC._queryparse = function (search) {
let params = {};
new URLSearchParams(search).forEach(function (v, k) {
// Note: technically the same key _could_ come twice
// ex: 'names[]=aj&names[]=ryan'
// (but we're ignoring that case)
params[k] = v;
});
return params;
};

OIDC.generateOidcUrl = function (
oidcBaseUrl,
client_id,
redirect_uri,
state,
scope = "",
login_hint = ""
) {
// response_type=id_token requires a nonce (one-time use random value)
// response_type=token (access token) does not
var nonce = crypto.randomUUID().replace(/-/g, "");
var options = { state, client_id, redirect_uri, scope, login_hint, nonce };
// transform from object to 'param1=escaped1&param2=escaped2...'
var params = OIDC._querystringify(options);

return `${oidcBaseUrl}?response_type=code&access_type=online&${params}`;
};

async function mustOk(resp) {
if (resp.statusCode >= 200 && resp.statusCode < 300) {
return resp;
}
throw Errors.OIDC_BAD_GATEWAY();
}

OIDC.getConfig = async function (issuer) {
// TODO check cache headers / cache for 5 minutes
let oidcUrl = issuer;
if (!oidcUrl.endsWith("/")) {
oidcUrl += "/";
}
oidcUrl += ".well-known/openid-configuration";

// See examples:
// Google: https://accounts.google.com/.well-known/openid-configuration
// Auth0: https://example.auth0.com/.well-known/openid-configuration
// Okta: https://login.writesharper.com/.well-known/openid-configuration
let resp = await request({ url: oidcUrl, json: true })
.then(mustOk)
.catch(function (err) {
console.error(`Could not get '${oidcUrl}':`);
console.error(err);
throw Errors.create(
"could not fetch OpenID Configuration - try inspecting the token and checking 'iss'",
{
code: "E_BAD_REMOTE",
status: 422,
}
);
});

return resp.body;
};
5 changes: 5 additions & 0 deletions lib/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ function verifyOidcToken(verifyOpts, tokenVerifier) {
// Only tokens signed by the expected issuer (such as https://accounts.google.com) are valid
return function (req, res, next) {
let jwt = (req.headers.authorization || "").replace(/^Bearer /, "");
if (verifyOpts._optional && !jwt) {
// for internal use (ex: google sign in redirect)
next();
return;
}
Keyfetch.jwt
.verify(jwt, verifyOpts)
.then(
Expand Down
5 changes: 4 additions & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
<h3>Social Login</h3>
<div>
<span class="js-social-login" hidden>
<a class="js-google-oidc-url">Google Sign In</a>
<a style="display: none" class="js-google-oidc-url">Google Sign In</a>
<a href="/api/authn/session/oidc/accounts.google.com/redirect"
>Google Sign In</a
>
|
<a class="js-github-oauth2-url">GitHub Sign In</a>
</span>
Expand Down

0 comments on commit 600f87d

Please sign in to comment.