shibuqam: provide a basic auth web service to simplify the use of `shibuqam`
authorJean Privat <jean@pryen.org>
Fri, 19 Aug 2016 20:20:19 +0000 (16:20 -0400)
committerJean Privat <jean@pryen.org>
Fri, 19 Aug 2016 20:20:19 +0000 (16:20 -0400)
Signed-off-by: Jean Privat <jean@pryen.org>

contrib/shibuqam/examples/shibuqamoauth.nit [new file with mode: 0644]

diff --git a/contrib/shibuqam/examples/shibuqamoauth.nit b/contrib/shibuqam/examples/shibuqamoauth.nit
new file mode 100644 (file)
index 0000000..2a52be9
--- /dev/null
@@ -0,0 +1,339 @@
+# This file is part of NIT ( http://www.nitlanguage.org ).
+#
+# Licensed 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 CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Server that implements an OAuth2-like authentication bound to a `shibuqam` valid server.
+#
+# The protocol is based on [OAuth2](https://tools.ietf.org/html/rfc6749),
+# especially the [Authorization Code Grant](https://tools.ietf.org/html/rfc6749#section-4.1).
+#
+# # Use as a web service
+#
+# * User: the human user and its user-agent (browser) that use the client website.
+# * Client: the 3rd party web site that need authentication. It owns `https://client.example.com`
+# * Server: the public OAuth server. It owns `https://server.example.com`
+#
+# From the user & client point of view, the process is the following:
+#
+# 1. The client redirects the user to the server.
+# 2. The user authenticates on the server.
+# 3. The server redirects the user to the client with a token.
+# 4. The client sends the token to the server.
+# 5. The server responds with the user information.
+#
+# ## 1. User Request
+#
+# Two GET fields
+#
+# * `redirect_uri`: A full URI that will be used to redirect the user once the authentication is done.
+# * `state`: A temporary string used to check that the callback is legitimate.
+#
+# On request, the service authenticate the user then redirect to the callback with the user informations.
+#
+# Example:
+#
+# ~~~raw
+# GET https://server.example.com/login?redirect_uri=https://client.example.com/callback&state=secret
+# ~~~
+#
+# ## 3. Response
+#
+# After the authentication, the response is a 303 redirection to `redirect_uri`
+# with the following GET arguments:
+#
+# * `code:` a temporary token to send-back to the server
+# * `state`: the same state to check that the response is not forged.
+#
+# Example:
+#
+# ~~~raw
+# HTTP/1.1 303 See Other
+# Location: https://client.example.com/callback?code=something&state=secret
+# ~~~
+#
+# ## 3b. Errors
+#
+# If the request is badly formed or if the `redirect_uri` is not authorized,
+# then there is no redirection and a text error message is send to the user.
+#
+# If there is a problem during the authentication, then there is a redirection
+# but the GET fields are only `state` and `error=access_denied`.
+#
+# ## 4. Client request
+#
+# A single POST field
+#
+# * `code`: The one you get as the user response.
+#
+# Example:
+#
+# ~~~raw
+# POST https://server.example.com/info
+# code=something
+# ~~~
+#
+# ## 5. Response
+#
+# The response is a JSON object that is the plain serialization of a `User` instance.
+#
+# ~~~json
+# HTTP/1.1 200 OK
+# Content-Type: application/json;charset=UTF-8
+#
+# {
+#      "id": "jdoe",
+#      "given_name": "John",
+#      "display_name": "John Doe",
+#      "email": "doe.john@example.com",
+#      "avatar": "https://www.gravatar.com/avatar/4fe50a575e1c28773800a0aa03c62dbe?d=retro"
+# }
+# ~~~
+#
+# # To configure and execute
+#
+# ## Run the server
+#
+#    shibuqamoauth authorized_list [host] [port]
+#
+# `authorized_list` is a textfile that registers the list of accepted `redirect_uri`.
+#
+# Example:
+#
+# ~~~raw
+# https://example.com/foo
+# https://other.example.com/
+# ~~~
+#
+# To be authorized, a `redirect_uri` must have one of the line of `authorized_list` as a prefix.
+# For instance, the previous `authorized_list` accepts `https://example.com/foo` and `https://example.com/foo/bar`
+# but not `https://example.com/bar` nor `https://sub.example.com/foo`.
+#
+#
+# ## Install as a server
+#
+# A correct `shibuqam` reverse proxy must be configured (see `shibuqam` for details).
+# In a full scenario, the server is replaced by:
+#
+# * Proxy: the public reverse proxy that can do the genuine Shibboleth authentication.
+#   It owns `https://server.example.com`
+# * Service: the private shibuqamoauth service. It owns a NATed `https://shibuqamoauth.example.com/`
+#
+# On the first access to the server (user request):
+#
+# 1. Proxy gets the request
+# 2. Proxy does the Shibboleth authentication.
+# 3. Proxy enhances the request header.
+# 4. Proxy forwards the request to service.
+# 5. Service checks that `redir` is authorized.
+# 6. Service digs in the enhanced request header to generate a token and associate it to the user.
+# 7. Redirect the user to client with the callback and the token.
+#
+# On the second access to the server (client request):
+#
+# 1. Proxy gets the request
+# 2. Proxy forwards the request to service without authentification (it is not a user).
+# 3. Service checks that the token existe and is not expired.
+# 4. Service serialize and send the user as in the
+#
+# The Proxy should only do the authentication on the user request.
+# The simplest way is to configure two routes that reverse proxy on the same server.
+#
+# # Why not using real OAuth2?
+#
+# OAuth2 is centered about *access_tokens* that allow clients to performs
+# a (possibly scoped) set of actions/queries on the behalf of an authenticated user.
+#
+# In our scenario, there is no action/queries to do once an user is authenticated.
+# So we do not delivers *access_tokens* since there is nothing to access once the user is known.
+#
+# We need only the third-party authentication part of the protocol.
+#
+# Since we do not have the same goals than the RFC, we also have a simplified protocol.
+# Noways, OAuth is mainly a framework and the implementations are very diverse and unfortunately no
+# not interoperable anyway.
+#
+# Here is the specific changes we have:
+#
+# * no `response_type`: there is a single kind of grant and the two steps are identified by the http method (GET or POST) and the configured routes on the server.
+# * no `client_id`: because we do not want to code a db of clients. The `redirect_uri` can be seen as a simple client_id. We have also no way to present the client to the user since we do not control the authorization page of shibboleth since is done by the reverse proxy.
+# * no `scope` and no `authorisation_code` since since there is nothing to access. We just get minimal information after the successful shibboleth login. Thus we have nothing more to authorise once the user is authenticated.
+# * no `client_secret`: the user information are already public. There is no need to make the code more complex to protect public information.
+module shibuqamoauth
+
+import popcorn
+import shibuqam
+import json::serialization
+
+redef class HttpRequest
+       # percent decoded get or post parameter.
+       fun dec_param(name: String): nullable String
+       do
+               var res = get_args.get_or_null(name)
+               if res == null then res = post_args.get_or_null(name)
+               if res == null then return null
+               return res.from_percent_encoding
+       end
+end
+
+class AuthHandler
+       super Handler
+
+       # List of prefixes of authorized redirections.
+       var authorized: Array[String]
+
+       # Check is `redir` is an authorized redirection (client)
+       fun is_authorized(redir: String): Bool
+       do
+               for r in authorized do if redir.has_prefix(r) then return true
+               return false
+       end
+
+
+       # Associate each `code` issued to the user, to the info intended to the client.
+       var infos = new HashMap[String, Info]
+
+       # Remove expired keys
+       fun expiration
+       do
+               var now = get_time
+               for k, i in infos do
+                       if i.expiration < now then
+                               infos.keys.remove(k)
+                               print "{i.user.id} -> expired"
+                       end
+               end
+       end
+
+       # Generate a new usable token
+       #
+       # Not thread safe!
+       fun new_token: String
+       do
+               var token
+               loop
+                       token = generate_token
+                       if not infos.has_key(token) then break
+               end
+               return token
+       end
+
+       redef fun get(http_request, response)
+       do
+               # GET means a Authorization Request from the user-agent
+
+               # Get the `redirect_uri` parameter, we use it to identify the client
+               var redir = http_request.dec_param("redir")
+               if redir == null then redir = http_request.dec_param("redirect_uri")
+               if redir == null then
+                       response.send("No redirect_uri.", 400)
+                       return
+               end
+
+               # Check if the client is authorized
+               if not is_authorized(redir) then
+                       response.send("Site not authorized.", 403)
+                       return
+               end
+
+               # Get the state, we use it to avoid CSRF attacks
+               var state = http_request.dec_param("state")
+               var res = redir + "?"
+
+               # If we are here, the reverse proxy did the authentication
+               # Is there an user?
+               var user = http_request.user
+               if user == null then
+                       print "no user -> {redir}"
+                       res += "error=access_denied"
+               else
+                       # The user is authenticated.
+                       print "{user.id} -> {redir}"
+
+                       # We prepare a token (code) that the client will use to get the information.
+                       expiration
+                       var token = new_token
+
+                       res += "code={token.to_percent_encoding}"
+
+                       var ttl = 10*60*60 # 10 minutes
+                       var info = new Info(get_time + ttl, user)
+                       infos[token] = info
+               end
+               if state != null then res += "&state={state.to_percent_encoding}"
+               response.redirect res
+       end
+
+       redef fun post(http_request, response)
+       do
+               # POST means an Access Token Request from the client.
+               # Unfortunately, we have no access to grant, only informations.
+               print http_request.post_args.join(" ; ", ": ")
+
+               expiration
+
+               var code = http_request.dec_param("code")
+               if code == null then
+                       print "POST: no code"
+                       return
+               end
+               var info = infos.get_or_null(code)
+               if info == null then
+                       print "POST: bad code {code}"
+                       return
+               end
+
+               print "{info.user.id} -> retrieved"
+
+               # Drop the code as it is a single use
+               infos.keys.remove(code)
+
+               # Send the requested information.
+               response.json(info.user)
+       end
+end
+
+redef class User
+       super Jsonable
+       redef fun to_json do return serialize_to_json(plain=true)
+end
+
+# Information about an authenticated used stored on the server to be given to the client.
+class Info
+       # Time to live
+       var expiration: Int
+
+       # The identified user
+       var user: User
+end
+
+var host = "localhost"
+var port = 3000
+
+if args.is_empty then
+       print "usage: shibuqamoauth authorized_list [host] [port]"
+       return
+end
+
+var list = args[0]
+if args.length > 1 then host = args[1]
+if args.length > 2 then port = args[2].to_i
+
+var authorized = list.to_path.read_lines
+if authorized.is_empty then
+       print_error "{list}: not found or empty"
+       exit 1
+end
+
+var app = new App
+app.use("/*", new AuthHandler(authorized))
+app.listen(host, port)