Skip to content
Open
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
10 changes: 10 additions & 0 deletions nginx-with-github-auth/http.conf
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,13 @@
proxy_cache_path /var/cache/nginx/ouath2 levels=1 keys_zone=ouath2:1m max_size=10m;

js_import nginx-with-github-auth/oauth2.js;

# njs (ngx.fetch) TLS trust — Alpine bundle path
js_fetch_trusted_certificate /etc/ssl/cert.pem;

js_fetch_verify on;
js_fetch_verify_depth 5;

# Single DNS resolver for outbound lookups
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;
275 changes: 162 additions & 113 deletions nginx-with-github-auth/oauth2.js
Original file line number Diff line number Diff line change
@@ -1,136 +1,179 @@
/**
* Verify the GitHub OAuth 2.0 token and make sure it belongs to a user that
* is member of the organization
*
* Note: Nginx JavaScript does not support many of the ES6 features, so we
* are restricted to using ES5 syntax
* GitHub OAuth2 for Nginx (njs) — direct HTTP via ngx.fetch
* - Exchanges ?code=… for access_token
* - Verifies org membership via GraphQL
* ES5 syntax where possible for compatibility.
*/

function authenticate(r) {
var code = getCode(r);
if (typeof code === 'string') {
requestToken(r, code);
} else {
verifyToken(r, r.variables.cookie_token);
}

if (typeof code === 'string') requestToken(code);
else verifyToken(r.variables.cookie_token);

/** Extract "code" query string argument from GitHub sign in page */
/** Extract "code" from the original request URI */
function getCode() {
var requestUrl = r.variables.auth_request_uri;
var queryString = requestUrl.split('?')[1];
if (queryString === undefined) return undefined;
var requestUrl = r.variables.auth_request_uri || '';
var qIdx = requestUrl.indexOf('?');
if (qIdx === -1) return undefined;

var queryString = requestUrl.substring(qIdx + 1);
if (!queryString) return undefined;

var parts = queryString.split('&');
if (parts === undefined) return undefined;
var code = undefined;
parts.forEach(function (part) {
var array = part.split('=');
var key = array[0];
var value = array[1];
if (key === 'code') code = value;
});
return code;
var i, kv, key, val;
for (i = 0; i < parts.length; i++) {
kv = parts[i].split('=');
if (kv.length !== 2) continue;
key = kv[0];
val = kv[1];
if (key === 'code') {
return decodeURIComponent(String(val || '').replace(/\+/g, ' '));
}
}
return undefined;
}

/** Turn a "code" query string argument into an authentication token */
function requestToken(code) {
r.subrequest(
'/_oauth2_send_login_request',
'code=' + code,
function (reply) {
if (reply.status !== 200)
return error(
'OAuth unexpected response from authorization server (HTTP ' +
reply.status +
'). ' +
reply.responseBody,
500
);

// We have a response from authorization server, validate it has expected JSON schema
try {
// Test for valid JSON so that we only store good responses
var response = JSON.parse(reply.responseBody);
if (response.error !== undefined)
return error(
response.error + '\n' + response.error_description,
500
);
verifyToken(response.access_token);
} catch (e) {
return error(
'OAuth token response is not JSON: ' + reply.responseBody,
500
);
}
}
);
/** Utility: form-encode key/value object */
function formEncode(obj) {
var out = [];
for (var k in obj) {
if (!obj.hasOwnProperty(k)) continue;
out.push(encodeURIComponent(k) + '=' + encodeURIComponent(String(obj[k])));
}
return out.join('&');
}

function error(error, httpCode) {
if (httpCode === undefined) httpCode = 401;
r.error(error);
r.return(httpCode);
/** Exchange authorization code for access_token using ngx.fetch */
function requestToken(r, code) {
var clientId = r.variables.oauth_client_id;
var clientSecret = r.variables.oauth_client_secret;

if (!clientId || !clientSecret) {
return error('Missing oauth_client_id or oauth_client_secret variables', 500);
}

var body = formEncode({
client_id: clientId,
client_secret: clientSecret,
code: code
// redirect_uri not required when single fixed callback matches app settings
});

ngx.fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'nginx'
},
body: body
})
.then(function(resp) {
if (!resp.ok) {
return resp.text().then(function(text) {
error('Token exchange failed HTTP ' + resp.status + ': ' + text, 500);
});
}
return resp.json().then(function(data) {
if (data && data.error !== undefined) {
return error(String(data.error) + (data.error_description ? '\n' + String(data.error_description) : ''), 500);
}
if (!data || !data.access_token) {
return error('OAuth token response missing access_token', 500);
}
verifyToken(r, data.access_token);
});
})
.catch(function(e) {
error('Token exchange exception: ' + e, 500);
});
}

/**
* Fetch list of teams and members of the organization
* If fails, it means the user is not part of the organization
*/
function verifyToken(token) {
/** Verify org membership via GitHub GraphQL using ngx.fetch */
function verifyToken(r, token) {
if (typeof token !== 'string' || token.length === 0) return r.return(401);

r.subrequest(
'/_oauth2_send_organization_info_request',
{
method: 'POST',
args: 'token=' + token,
body: JSON.stringify({
query:
'{\norganization(login: "' +
r.variables.github_organization +
'") {\nteams(first: 40) {\nnodes {\nname\nmembers(first: 40) {\nnodes {\nlogin\n}\n}\n}\n}\n}\nviewer {\nname\nlogin\n}\n}',
}),
var org = r.variables.github_organization;
if (!org) {
return error('Missing github_organization variable', 500);
}

var query =
'{\n' +
' organization(login: "' + org + '") {\n' +
' teams(first: 40) {\n' +
' nodes {\n' +
' name\n' +
' members(first: 40) { nodes { login } }\n' +
' }\n' +
' }\n' +
' }\n' +
' viewer { name login }\n' +
'}';

ngx.fetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Accept': 'application/json',
'Content-Type': 'application/json',
'User-Agent': 'nginx'
},
function (reply) {
try {
// Test for valid JSON so that we only store good responses
var response = JSON.parse(reply.responseBody);
var teams = {};
response.data.organization.teams.nodes.forEach(function (node) {
teams[node.name] = node.members.nodes.map(function (node) {
return node.login;
});
});

if (Object.keys(teams).length === 0)
return error(
'Not a member of ' +
r.variables.github_organization +
' organization',
403
);
r.log(
'OAuth2 Authentication successful. GitHub Login: ' +
response.data.viewer.login
);
tokenResult({
token: token,
name: response.data.viewer.name,
login: response.data.viewer.login,
organization: {
teams: teams,
},
});
} catch (e) {
return error(
'OAuth token introspection response is not JSON: ' +
reply.responseBody,
500
);
}
body: JSON.stringify({ query: query })
})
.then(function(resp) {
if (!resp.ok) {
return resp.text().then(function(text) {
error('Org check failed HTTP ' + resp.status + ': ' + text, 500);
});
}
);
return resp.json().then(function(data) {
if (!data || data.errors) {
return error('GraphQL error: ' + JSON.stringify(data && data.errors ? data.errors : data), 500);
}

var orgNode = data.data && data.data.organization;
var viewer = data.data && data.data.viewer;
if (!orgNode || !orgNode.teams || !orgNode.teams.nodes) {
return error('Organization info missing in GraphQL response: ' + JSON.stringify(data), 403);
}

// Build simple map of team -> [logins]
var teamsArr = orgNode.teams.nodes || [];
var teams = {};
for (var i = 0; i < teamsArr.length; i++) {
var t = teamsArr[i];
var members = (t.members && t.members.nodes) || [];
var logins = [];
for (var j = 0; j < members.length; j++) {
logins.push(members[j].login);
}
teams[t.name] = logins;
}

if (!Object.keys(teams).length) {
return error('Not a member of ' + org + ' organization', 403);
}

r.log('OAuth2 Authentication successful. GitHub Login: ' + (viewer && viewer.login ? viewer.login : '(unknown)'));

tokenResult(r, {
token: token,
name: viewer && viewer.name,
login: viewer && viewer.login,
organization: { teams: teams }
});
});
})
.catch(function(e) {
error('Org check exception: ' + e, 500);
});
}

function tokenResult(response) {
function tokenResult(r, response) {
// Check for validation success
// Iterate over all members of the response and return them as response headers
r.headersOut['token'] = response.token;
Expand All @@ -139,6 +182,12 @@ function authenticate(r) {
r.sendHeader();
r.finish();
}

function error(msg, httpCode) {
if (httpCode === undefined) httpCode = 401;
r.error(msg);
r.return(httpCode);
}
}

export default { authenticate };
30 changes: 22 additions & 8 deletions nginx-with-github-auth/server.conf
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,28 @@ location = /_oauth2_send_login_request {
internal;
gunzip on; # Decompress if necessary

proxy_set_header Content-Type "application/json";
proxy_set_header Accept "application/json";
proxy_set_header User-Agent "nginx";
proxy_method POST;
# See https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#2-users-are-redirected-back-to-your-site-by-github
# proxy_pass "https://github.com/login/oauth/access_token/$is_args$args";
# proxy_pass "https://files.specifysoftware.org/some/path?client_id=$oauth_client_id&client_secret=$oauth_client_secret&code=$arg_code";
proxy_pass "https://github.com/login/oauth/access_token?client_id=$oauth_client_id&client_secret=$oauth_client_secret&code=$arg_code";
proxy_http_version 1.1;
proxy_pass https://github.com/login/oauth/access_token;

# REQUIRED headers/body for GitHub OAuth token endpoint
proxy_set_header Host github.com;
proxy_set_header Content-Type application/x-www-form-urlencoded;
proxy_set_header Accept application/json;
proxy_set_header User-Agent nginx;

# Send form-encoded body (no query string)
proxy_set_body "client_id=$oauth_client_id&client_secret=$oauth_client_secret&code=$arg_code";

# Ensure Nginx computes the correct Content-Length for the rewritten body
proxy_pass_request_body on;
proxy_set_header Content-Length "";

# TLS/SNI
proxy_ssl_server_name on;

# (Optional) give GitHub time to reply
proxy_read_timeout 30s;
}

location = /_oauth2_send_organization_info_request {
Expand All @@ -45,4 +59,4 @@ location @autherror {
# If the user is not logged in, redirect them to GitHub's login URL
# See https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#1-request-a-users-github-identity
return 302 "https://github.com/login/oauth/authorize?client_id=$oauth_client_id&scope=$github_scopes";
}
}