git.fiddlerwoaroof.com
cl-oid-connect.lisp
373a5a27
 ;;;; cl-oid-connect.lisp
e09b98ca
 ;;;; TODO: Need to refactor out server names!!!
373a5a27
 #|
a43b3cd8
 |Copyright (c) 2015 Edward Langley
 |All rights reserved.
 |
 |Redistribution and use in source and binary forms, with or without
 |modification, are permitted provided that the following conditions
 |are met:
 |
 |Redistributions of source code must retain the above copyright notice,
 |this list of conditions and the following disclaimer.
 |
 |Redistributions in binary form must reproduce the above copyright
 |notice, this list of conditions and the following disclaimer in the
 |documentation and/or other materials provided with the distribution.
 |
 |Neither the name of the project's author nor the names of its
 |contributors may be used to endorse or promote products derived from
 |this software without specific prior written permission.
 |
 |THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 |"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 |LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
 |FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 |HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 |SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES  INCLUDING, BUT NOT LIMITED
 |TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 |PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 |LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 |NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 |SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 |
 |#
373a5a27
 
 (in-package :cl-oid-connect)
a43b3cd8
 ; Should this be here?
17e50f7b
 
6aeb2c70
 (eval-when (:compile-toplevel :execute :load-toplevel)
17e50f7b
   (defun vars-to-symbol-macrolets (vars obj)
6aeb2c70
     (iterate:iterate (iterate:for (store key) in (ensure-mapping vars))
                      (iterate:collect `(,store (gethash ,(alexandria:make-keyword key) ,obj))))))
17e50f7b
 
 (defmacro with-session-values (vars session &body body)
   (alexandria:once-only (session)
     `(symbol-macrolet ,(vars-to-symbol-macrolets vars session)
        ,@body)))
 
6aeb2c70
 ; This probably should eventually go?
 (defmacro with-endpoints (endpoint-schema  &body body)
   `(let* ((*endpoint-schema* ,endpoint-schema))
      ,@body))
 
 (defmacro with-session ((var) &body body)
   `(progn
      (format t "The session var is: ~a it contains: ~a~%"  ,(symbol-name var) ,var)
      (let ((,var (context :session)))
        (format t "The session var is: ~a it now contains: ~a~%"  ,(symbol-name var) ,var)
        ,@body)))
 
 (defmacro def-route ((url args &key (app *oid*) (method :GET)) &body body)
   `(setf (ningle:route ,app ,url :method ,method)
          #'(lambda ,args
              (declare (ignorable ,@args))
              ,@body)))
 
2b1f7ddf
 (defparameter *oid* (make-instance 'ningle:<app>))
a43b3cd8
 (setf drakma:*text-content-types* (cons '("application" . "json") drakma:*text-content-types*))
 
3c2c399b
 (setf =service-info= (object :parents '()
                              :properties '((client-id nil :accessor client-id)
                                            (secret nil :accessor secret))))
 
 (setf =endpoint-schema= (object :parents '()
                                 :properties '((auth-endpoint nil :accessor auth-endpoint)
                                               (token-endpoint nil :accessor token-endpoint)
                                               (userinfo-endpoint nil :accessor t)
e09b98ca
                                               (auth-scope "openid profile email" :accessor t)
3c2c399b
                                               (redirect-uri nil :accessor t))))
a43b3cd8
 (sheeple:defmessage get-user-info (a b))
 (sheeple:defmessage get-access-token (a b))
 
 (sheeple:defreply get-user-info ((a =endpoint-schema=) (b sheeple:=string=)))
 (sheeple:defreply get-access-token ((a =endpoint-schema=) (b sheeple:=string=)))
 
67a3d329
 (defparameter *fbook-info* (sheeple:clone =service-info=))
 (defparameter *goog-info* (sheeple:clone =service-info=))
2b1f7ddf
 (defparameter *endpoint-schema* nil)
 (defparameter *goog-endpoint-schema* (defobject (=endpoint-schema= *goog-info*)))
a43b3cd8
 
 (defproto *fbook-endpoint-schema* (=endpoint-schema= *fbook-info*)
           ((auth-endpoint "https://www.facebook.com/dialog/oauth")
            (token-endpoint "https://graph.facebook.com/v2.3/oauth/access_token")
3c2c399b
            (userinfo-endpoint "https://graph.facebook.com/v2.3/me")
e09b98ca
            (auth-scope "email")
65485bfc
            (redirect-uri  "http://srv2.elangley.org:9090/oidc_callback/facebook")))
a43b3cd8
 
 (sheeple:defreply get-access-token ((endpoint-schema *fbook-endpoint-schema*) (code sheeple:=string=))
   (cl-json:decode-json-from-string
     (drakma:http-request (token-endpoint endpoint-schema)
                          :method :post
                          :redirect nil
                          :parameters `(("code" . ,code)
                                        ("client_id" . ,(client-id endpoint-schema))
                                        ("app_id" . ,(client-id endpoint-schema))
                                        ("client_secret" . ,(secret endpoint-schema))
                                        ("redirect_uri" . ,(redirect-uri endpoint-schema))
                                        ("grant_type" . "authorization_code")
                                        ("")
                                        ))))
 
 (sheeple:defreply get-user-info ((endpoint-schema *fbook-endpoint-schema*) (access-token sheeple:=string=))
   (let ((endpoint (userinfo-endpoint endpoint-schema)))
3573d7f1
     (cl-json:decode-json-from-string
       (drakma:http-request endpoint
                            :parameters `(("access_token" . ,access-token))))))
 
2b1f7ddf
 (defmacro string-assoc (key alist) `(assoc ,key ,alist :test #'equal))
89bed873
 (defmacro assoc-cdr (key alist &optional (test '#'eql)) `(cdr (assoc ,key ,alist :test ,test)))
3573d7f1
 
2b1f7ddf
 (defun discover-endpoints (schema discovery-doc-url &key (gat nil gat-p) (gui nil gui-p))
6aeb2c70
   "Discover endpoints on the basis of a discovery document stored at a particular url.
    The two keyword arguments define a function to bind to sheeple replies for get-user-token
    and get-access-token."
   (prog1 schema
     (let ((discovery-document (cl-json:decode-json-from-string (drakma:http-request discovery-doc-url))))
       (setf (auth-endpoint schema) (assoc-cdr :authorization--endpoint discovery-document)
             (token-endpoint schema) (assoc-cdr :token--endpoint discovery-document)
             (userinfo-endpoint schema) (assoc-cdr :userinfo--endpoint discovery-document))
       (when gui-p
         (format t "defining gui-p")
         (sheeple:defreply get-user-info ((a schema)) (funcall gui a)))
       (when gat-p
         (format t "defining gat-p")
         (sheeple:defreply get-access-token ((a schema) (b sheeple:=string=))
           (funcall gat a b))))))
373a5a27
 
a43b3cd8
 (defun do-auth-request (endpoint-schema state)
   (drakma:http-request (auth-endpoint endpoint-schema)
373a5a27
                        :redirect nil
a43b3cd8
                        :parameters `(("client_id" . ,(client-id endpoint-schema))
                                      ("app_id" . ,(client-id endpoint-schema))
373a5a27
                                      ("response_type" . "code")
e09b98ca
                                      ("scope" . ,(auth-scope endpoint-schema))
a43b3cd8
                                      ("redirect_uri" . ,(redirect-uri endpoint-schema))
373a5a27
                                      ("state" . ,state))))
 
 (defun gen-state (len)
   (with-output-to-string (stream)
     (let ((*print-base* 36))
       (loop repeat len
             do (princ (random 36) stream)))))
 
2b1f7ddf
 
6aeb2c70
 (defun valid-state (received-state)
   (let* ((session (context :session))
          (saved-state (gethash :state session)))
     (equal saved-state received-state)))
373a5a27
 
1cbcdda3
 (defmacro auth-entry-point (name endpoint-schema)
   `(defun ,name (params)
      (declare (ignore params))
      (with-session-values (state endpoint-schema) (context :session)
        (setf state (gen-state 36)
              endpoint-schema ,endpoint-schema)
        (with-endpoints ,endpoint-schema
          (multiple-value-bind (content rcode headers uri) (do-auth-request ,endpoint-schema state)
            (declare (ignore headers))
            (if (< rcode 400) `(302 (:location ,(format nil "~a" uri)))
              content))))))
 
65485bfc
 (flet ((get-code (params) (assoc-cdr "code" params #'equal)))
   (defun run-callback-function (endpoint-schema params get-login-data get-app-user-cb)
     (let ((a-t (get-access-token endpoint-schema (get-code params))))
       (auth-callback-skeleton params (:endpoint-schema endpoint-schema
                                       :auth-session-vars (accesstoken userinfo idtoken app-user))
         (multiple-value-bind (access-token user-info id-token) (funcall get-login-data a-t)
           (setf accesstoken access-token
                 userinfo user-info
                 idtoken id-token
                 app-user (funcall get-app-user-cb user-info id-token access-token)))
         '(302 (:location "/"))))))
 
1cbcdda3
 (defmacro def-callback-generator (name generator-args callback-args &body body)
   `(defun ,name ,generator-args
      (lambda ,callback-args
        ,@body)))
 
 (defmacro reject-when-state-invalid (params &body body)
   (alexandria:with-gensyms (received-state)
     (alexandria:once-only (params)
       `(let ((,received-state (cdr (string-assoc "state" ,params))))
          (if (not (valid-state ,received-state))
            '(403 '() "Out, vile imposter!")
         ,@body)))))
 
 (defmacro auth-callback-skeleton (params (&key endpoint-schema auth-session-vars) &body body)
   (alexandria:with-gensyms (session)
     (alexandria:once-only (params endpoint-schema)
       `(reject-when-state-invalid ,params
          (with-endpoints ,endpoint-schema
            (my-with-context-variables ((,session session))
65485bfc
              ,(if (null auth-session-vars)
                 `(progn
                    ,@body)
                 `(with-session-values ,auth-session-vars ,session
                    ,@body))))))))
1cbcdda3
 
6aeb2c70
 (define-condition user-not-logged-in (error) ())
373a5a27
 
6aeb2c70
 (defmacro my-with-context-variables ((&rest vars) &body body)
   "This improves fukamachi's version by permitting the variable to be stored somewhere
    besides the symbol corresponding to the keyword."
   `(symbol-macrolet
        ,(loop for (var key) in (ensure-mapping vars)
               for form = `(context ,(intern (string key) :keyword))
               collect `(,var ,form))
      ,@body))
89bed873
 
6aeb2c70
 (defmacro ensure-logged-in (&body body)
   "Ensure that the user is logged in: otherwise throw the condition user-not-logged-in"
   (alexandria:with-gensyms (session userinfo)
     `(my-with-context-variables ((,session session))
        (with-session-values ((,userinfo userinfo)) ,session
          (if (null ,userinfo)
            (error 'user-not-logged-in)
            (progn ,@body))))))
 
 (flet
   ((handle-no-user (main-body handler-body)
      `(handler-case
         (ensure-logged-in ,@main-body)
         (user-not-logged-in (e)
                             (declare (ignorable e))
                             ,@handler-body))))
 
   (defmacro check-login (&body body)
     "Returns an HTTP 401 Error if not logged in."
     (handle-no-user body `('(401 () "Unauthorized"))))
 
   (defmacro require-login (&body body)
     "Redirects to /login if not logged in."
     (handle-no-user body
                     `((with-session-values (next-page) (context :session)
                         (setf next-page (lack.request:request-path-info *request*))
                         '(302 (:location "/login")))))))
3573d7f1
 
2b1f7ddf
 (defun load-facebook-info (loadfrom)
   (with-open-file (fbook-info (truename loadfrom))
     (let* ((data (yason:parse fbook-info))
            (client-id (gethash "client-id" data))
            (secret (gethash "secret" data)))
e09b98ca
       (setf (client-id *FBOOK-INFO*) client-id)
2b1f7ddf
       (setf (secret *FBOOK-INFO*) secret))))
373a5a27
 
2b1f7ddf
 (defun load-google-info (loadfrom)
   (with-open-file (goog-info (truename loadfrom))
     (let* ((data (yason:parse goog-info))
            (client-id (gethash "client-id" data))
            (secret (gethash "secret" data)))
       (setf (client-id *GOOG-INFO*) client-id)
       (setf (secret *GOOG-INFO*) secret))))
3573d7f1
 
2b1f7ddf
 (defun goog-get-access-token (endpoint-schema code)
   (cl-json:decode-json-from-string
     (drakma:http-request (token-endpoint endpoint-schema)
                          :method :post
                          :redirect nil
                          :parameters `(("code" . ,code)
                                        ("client_id" . ,(client-id endpoint-schema))
                                        ("client_secret" . ,(secret endpoint-schema))
                                        ("redirect_uri" . ,(redirect-uri endpoint-schema))
                                        ("grant_type" . "authorization_code")))))
3573d7f1
 
2b1f7ddf
 (defun load-goog-endpoint-schema ()
   (discover-endpoints *goog-endpoint-schema*
                       "https://accounts.google.com/.well-known/openid-configuration"
                       :gat #'goog-get-access-token)
65485bfc
   (setf (redirect-uri *goog-endpoint-schema*) "http://srv2.elangley.org:9090/oidc_callback/google"))
3573d7f1
 
e09b98ca
 (sheeple:defreply get-user-info ((endpoint-schema *goog-endpoint-schema*) (access-token sheeple:=string=))
   (format t "getting user data: ~a~%" "blarg")
   (let ((endpoint (userinfo-endpoint endpoint-schema)))
     (cl-json:decode-json-from-string
       (drakma:http-request endpoint
                            :parameters `(("alt" . "json")
6aeb2c70
                                          ("access_token" . ,access-token))))))
 
 (auth-entry-point google-login-entry *goog-endpoint-schema*)
 (auth-entry-point facebook-login-entry *fbook-endpoint-schema*)
 
65485bfc
 (labels ((get-real-access-token (a-t) (assoc-cdr :access--token a-t))
          (get-id-token (a-t) (cljwt:decode (assoc-cdr :id--token a-t) :fail-if-unsupported nil))
          (get-login-data (a-t)
            (let ((access-token (get-real-access-token a-t)))
              (values access-token
                      (get-user-info *goog-endpoint-schema* access-token)
                      (get-id-token a-t)))))
6aeb2c70
 
   (def-callback-generator google-callback (get-app-user-cb) (params)
65485bfc
     (run-callback-function *goog-endpoint-schema* params #'get-login-data get-app-user-cb)))
1cbcdda3
 
65485bfc
 (labels ((get-id-token (a-t) (assoc-cdr :access--token a-t)) ; <-- access--token is not a mistake 
          (get-login-data (a-t)
            (let ((id-token (get-id-token a-t)))
              (values a-t (get-user-info *fbook-endpoint-schema* id-token) id-token))))
6aeb2c70
 
   (def-callback-generator facebook-callback (get-app-user-cb) (params)
65485bfc
     (run-callback-function *fbook-endpoint-schema* params #'get-login-data get-app-user-cb)))
67a3d329
 
 (defun userinfo-route (params)
   (declare (ignore params))
   (with-context-variables (session)
     (require-login
       (with-endpoints  (gethash :endpoint-schema session)
         (cl-json:encode-json-to-string (gethash :userinfo session))))))
 
 (defun logout-route (params)
   (declare (ignore params))
   (with-context-variables (session)
     (setf (gethash :userinfo session) nil)
     '(302 (:location "/"))))
 
 (defun oauth2-login-middleware (app &key google-info facebook-info (login-callback #'identity))
   (load-facebook-info facebook-info)
   (load-goog-endpoint-schema)
   (load-google-info google-info)
   (setf (route app "/userinfo.json" :method :get) #'userinfo-route
         (route app "/logout"  :method :get) #'logout-route
         (route app "/login/google" :method :get) #'google-login-entry
6aeb2c70
         (route app "/login/facebook" :method :get) #'facebook-login-entry
67a3d329
         (route app "/oidc_callback/google" :method :get) (google-callback login-callback)
         (route app "/oidc_callback/facebook" :method :get) (facebook-callback login-callback))
   (lambda (app) (lambda (env) (funcall app env))))
2b1f7ddf
 
 (defmacro redirect-if-necessary (sessionvar &body body)
   (with-gensyms (session)
     `(let* ((,session ,sessionvar)
             (next-page (gethash :next-page ,session)))
        (if (and (not (null next-page))
                 (not (string= next-page (lack.request:request-path-info *request*))))
          (progn
            (setf (gethash :next-page ,session) nil)
            `(302 (:location ,next-page)))
          ,@body))))