diff --git a/README.md b/README.md index 01177db..ebb4c21 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,32 @@ variables are: returned from Google (portion left of '@' in email). - **$ngo_email_as_user** If set and `$ngo_user` is defined, username returned will be full email address. +- **$ngo_email** Optional, boolean. If set to true, it will be populated with + the OAuth email returned from Google. +- **$ngo_name** Optional, boolean. If set to true, it will be populated with + the OAuth name returned from Google. +- **$ngo_groups** Optional, boolean. If set to true, it will be populated with + the Google Directory Groups of which the user is a member, within the Google + Suite domain defined in variable **$ngo_groups_domain**. + Requires the definition of **$ngo_service_account_json_file**, + **$ngo_organization_admin_email** and **$ngo_groups_domain**. +- **$ngo_allowed_groups** Optional, space separated list of email addresses of + Google Directory Groups. If set, will be used for access control, so that + only members of the defined groups will be authorized. + Requires the definition of **$ngo_service_account_json_file**, + **$ngo_organization_admin_email** and **$ngo_groups_domain**. +- **$ngo_service_account_json_file** Optional, path to JSON credentials file of the Google Service Account which has been granted domain-wide-delegation of the Google Suite Domain. +Please follow the [official documentation](https://developers.google.com/admin-sdk/directory/v1/guides/delegation) to setup Google Suite Domain-Wide Delegation of Authority and define at least the following scopes: +``` +https://www.googleapis.com/auth/admin.directory.user.readonly +https://www.googleapis.com/auth/admin.directory.group.readonly +https://www.googleapis.com/auth/admin.directory.group.member.readonly +``` +- **$ngo_organization_admin_email** Optional, the email of a Google Suite + administrator account. The Service Account will impersonate this user to + access Google Directory API. +- **ngo_groups_domain** Optional, the domain of the Google Suite account where + groups are fetched from. ## Available endpoints @@ -80,6 +106,8 @@ Endpoint that reports your OAuth token in a JSON object: ```json { "email": "foo@example.com", + "name": "Foo Name", + "groups": "group1@example.com group2@example.com", "token": "abc..xyz", "expires": 1445455680 } @@ -91,6 +119,8 @@ Endpoint that reports your OAuth token in text format: ``` email: foo@example.com +name: Foo Name +groups: group1@example.com group2@example.com token: abc..xyz expires: 1445455680 ``` @@ -100,7 +130,7 @@ expires: 1445455680 Endpoint that reports your OAuth token as `curl` arguments for header auth: ``` --H "OauthEmail: foo@example.com" -H "OauthAccessToken: abc..xyz" -H "OauthExpires: 1445455680" +-H "OauthEmail: foo@example.com" -H "OauthName: Foo Name" -H "OauthGroups: group1@example.com group2@example.com" -H "OauthAccessToken: abc..xyz" -H "OauthExpires: 1445455680" ``` You can add it to your `curl` command to make it work with OAuth. diff --git a/access.lua b/access.lua index 0452e85..bb36591 100644 --- a/access.lua +++ b/access.lua @@ -24,7 +24,14 @@ local blacklist = ngx.var.ngo_blacklist or "" local secure_cookies = ngx.var.ngo_secure_cookies == "true" or false local http_only_cookies = ngx.var.ngo_http_only_cookies == "true" or false local set_user = ngx.var.ngo_user or false +local set_email = ngx.var.ngo_email or false +local set_name = ngx.var.ngo_name or false local email_as_user = ngx.var.ngo_email_as_user == "true" or false +local sa_json_file = ngx.var.ngo_service_account_json_file or false +local org_admin_email = ngx.var.ngo_organization_admin_email or false +local set_groups = ngx.var.ngo_groups or false +local allowed_groups = ngx.var.ngo_allowed_groups or "" +local groups_domain = ngx.var.ngo_groups_domain or false if whitelist:len() == 0 then whitelist = nil @@ -34,11 +41,17 @@ if blacklist:len() == 0 then blacklist = nil end -local function handle_token_uris(email, token, expires) +if allowed_groups:len() == 0 then + allowed_groups = nil +end + +local function handle_token_uris(email, name, groups, token, expires) if uri == "/_token.json" then - ngx.header["Content-type"] = "application/json" + ngx.header["Content-type"] = "application/json; charset=utf-8" ngx.say(json.encode({ email = email, + name = name, + groups = groups, token = token, expires = expires, })) @@ -46,14 +59,14 @@ local function handle_token_uris(email, token, expires) end if uri == "/_token.txt" then - ngx.header["Content-type"] = "text/plain" - ngx.say("email: " .. email .. "\n" .. "token: " .. token .. "\n" .. "expires: " .. expires .. "\n") + ngx.header["Content-type"] = "text/plain; charset=utf-8" + ngx.say("email: " .. email .. "\n" .. "name: " .. name .. "\n" .. "groups: " .. groups .. "\n" .. "token: " .. token .. "\n" .. "expires: " .. expires .. "\n") ngx.exit(ngx.OK) end if uri == "/_token.curl" then - ngx.header["Content-type"] = "text/plain" - ngx.say("-H \"OauthEmail: " .. email .. "\" -H \"OauthAccessToken: " .. token .. "\" -H \"OauthExpires: " .. expires .. "\"\n") + ngx.header["Content-type"] = "text/plain; charset=utf-8" + ngx.say("-H \"OauthEmail: " .. email .. "\" -H \"OauthName: " .. name .. "\" -H \"OauthGroups: " .. groups .. "\" -H \"OauthAccessToken: " .. token .. "\" -H \"OauthExpires: " .. expires .. "\"\n") ngx.exit(ngx.OK) end end @@ -73,7 +86,7 @@ local function check_domain(email, whitelist_failed) end end -local function on_auth(email, token, expires) +local function on_auth(email, name, groups, token, expires) if blacklist then -- blacklisted user is always rejected if string.find(" " .. blacklist .. " ", " " .. email .. " ", 1, true) then @@ -92,6 +105,19 @@ local function on_auth(email, token, expires) check_domain(email, false) end + if allowed_groups then + local allow_group = false + for group in groups:gmatch("%S+") do + if string.find(" " .. allowed_groups .. " ", " " .. group .. " ", 1, true) then + allow_group = true + break + end + end + if not allow_group then + ngx.log(ngx.ERR, "none of the user groups (" .. groups .. ") are present in allowed_groups (" .. allowed_groups .. ")") + return ngx.exit(ngx.HTTP_FORBIDDEN) + end + end if set_user then if email_as_user then @@ -101,7 +127,19 @@ local function on_auth(email, token, expires) end end - handle_token_uris(email, token, expires) + if set_email then + ngx.var.ngo_email = email + end + + if set_name then + ngx.var.ngo_name = name + end + + if set_groups then + ngx.var.ngo_groups = groups + end + + handle_token_uris(email, name, groups, token, expires) end local function request_access_token(code) @@ -134,6 +172,79 @@ local function request_access_token(code) return json.decode(res.body) end +local function base64url(text) + return ngx.encode_base64(text):gsub("+", "-"):gsub("/", "_") +end + +local function request_groups(email) + if not (sa_json_file and org_admin_email and groups_domain) then + return "" + end + + local digest = require("openssl.digest") + local pkey = require("openssl.pkey") + + local json_file = io.open(sa_json_file, "r") + if json_file then + service_account = json.decode(json_file:read("*a")) + io.close(json_file) + else + ngx.log(ngx.ERR, "failed to open service account JSON file: " .. sa_json_file) + return "" + end + + local now = os.time() + local header = '{"alg":"RS256","typ":"JWT"}' + local claims = '{"iss":"' .. service_account["client_email"] .. '","sub":"' .. org_admin_email .. '", "scope":"https://www.googleapis.com/auth/admin.directory.user.readonly https://www.googleapis.com/auth/admin.directory.group.readonly https://www.googleapis.com/auth/admin.directory.group.member.readonly", "aud":"https://www.googleapis.com/oauth2/v4/token","exp":' .. now + 300 .. ', "iat":' .. now .. '}' + local signature = pkey.new(service_account["private_key"]):sign(digest.new("sha256"):update(base64url(header) .. "." .. base64url(claims))) + local assertion = base64url(header) .. "." .. base64url(claims) .. "." .. base64url(signature) + + local request = http.new() + request:set_timeout(7000) + local res, err = request:request_uri("https://www.googleapis.com/oauth2/v4/token", { + method = "POST", + body = ngx.encode_args({ + assertion = assertion, + grant_type = "urn:ietf:params:oauth:grant-type:jwt-bearer", + }), + headers = { + ["Content-type"] = "application/x-www-form-urlencoded" + }, + ssl_verify = true, + }) + if not res then + return nil, (err or "domain auth token request failed: " .. (err or "unknown reason")) + end + if res.status ~= 200 then + return nil, "received " .. res.status .. " from https://www.googleapis.com/oauth2/v4/token: " .. res.body + end + access_token = json.decode(res.body)['access_token'] + + local request = http.new() + request:set_timeout(7000) + local res, err = request:request_uri("https://www.googleapis.com/admin/directory/v1/groups?domain=" .. groups_domain .. "&userKey=" .. email, { + headers = { + ["Authorization"] = "Bearer " .. access_token, + }, + ssl_verify = true, + }) + if not res then + return nil, "auth info request failed: " .. (err or "unknown reason") + end + + if res.status ~= 200 then + return nil, "received " .. res.status .. " from https://www.googleapis.com/admin/directory/v1/groups?domain=" .. groups_domain .. "&userKey=" .. email + end + + local user_groups = json.decode(res.body) + local groups = "" + for i, group in ipairs(user_groups["groups"]) do + groups = groups .. " " .. group["email"] + end + + return groups +end + local function request_profile(token) local request = http.new() @@ -161,6 +272,8 @@ local function is_authorized() local expires = tonumber(ngx.var.cookie_OauthExpires) or 0 local email = ngx.unescape_uri(ngx.var.cookie_OauthEmail or "") + local name = ngx.unescape_uri(ngx.var.cookie_OauthName or "") + local groups = ngx.unescape_uri(ngx.var.cookie_OauthGroups or "") local token = ngx.unescape_uri(ngx.var.cookie_OauthAccessToken or "") if expires == 0 and headers["oauthexpires"] then @@ -171,14 +284,23 @@ local function is_authorized() email = headers["oauthemail"] end + if name:len() == 0 and headers["oauthname"] then + name = headers["oauthname"] + end + + if groups:len() == 0 and headers["oauthgroups"] then + groups = headers["oauthgroups"] + end + if token:len() == 0 and headers["oauthaccesstoken"] then token = headers["oauthaccesstoken"] end local expected_token = ngx.encode_base64(ngx.hmac_sha1(token_secret, cb_server_name .. email .. expires)) + local expected_token = ngx.encode_base64(ngx.hmac_sha1(token_secret, cb_server_name .. email .. name .. groups .. expires)) if token == expected_token and expires and expires > ngx.time() - extra_validity then - on_auth(email, expected_token, expires) + on_auth(email, name, groups, expected_token, expires) return true else return false @@ -189,7 +311,7 @@ local function redirect_to_auth() -- google seems to accept space separated domain list in the login_hint, so use this undocumented feature. return ngx.redirect("https://accounts.google.com/o/oauth2/auth?" .. ngx.encode_args({ client_id = client_id, - scope = "email", + scope = "email profile", response_type = "code", redirect_uri = cb_url, state = redirect_url, @@ -229,12 +351,21 @@ local function authorize() end local email = profile["email"] - local user_token = ngx.encode_base64(ngx.hmac_sha1(token_secret, cb_server_name .. email .. expires)) + local name = profile["name"] + + local groups, groups_err = request_groups(email) + if not groups then + ngx.log(ngx.ERR, "got error during groups request: " .. groups_err) + groups = "" + end + local user_token = ngx.encode_base64(ngx.hmac_sha1(token_secret, cb_server_name .. email .. name .. groups .. expires)) - on_auth(email, user_token, expires) + on_auth(email, name, groups, user_token, expires) ngx.header["Set-Cookie"] = { "OauthEmail=" .. ngx.escape_uri(email) .. cookie_tail, + "OauthName=" .. ngx.escape_uri(name) .. cookie_tail, + "OauthGroups=" .. ngx.escape_uri(groups) .. cookie_tail, "OauthAccessToken=" .. ngx.escape_uri(user_token) .. cookie_tail, "OauthExpires=" .. expires .. cookie_tail, }