;; disfluid, implementation of the Solid specification ;; Copyright (C) 2020, 2021 Vivien Kraus ;; This program is free software: you can redistribute it and/or modify ;; it under the terms of the GNU Affero General Public License as ;; published by the Free Software Foundation, either version 3 of the ;; License, or (at your option) any later version. ;; This program is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU Affero General Public License for more details. ;; You should have received a copy of the GNU Affero General Public License ;; along with this program. If not, see . (define-module (webid-oidc client) #:use-module (webid-oidc errors) #:use-module (webid-oidc provider-confirmation) #:use-module (webid-oidc oidc-configuration) #:use-module (webid-oidc oidc-id-token) #:use-module (webid-oidc dpop-proof) #:use-module (webid-oidc jwk) #:use-module ((webid-oidc parameters) #:prefix p:) #:use-module ((webid-oidc stubs) #:prefix stubs:) #:use-module ((webid-oidc config) #:prefix cfg:) #:use-module ((webid-oidc client accounts) #:prefix client:) #:use-module (web uri) #:use-module (web client) #:use-module (web request) #:use-module (web response) #:use-module (web server) #:use-module (web http) #:use-module (ice-9 optargs) #:use-module (ice-9 receive) #:use-module (srfi srfi-9) #:use-module (srfi srfi-19) #:use-module (srfi srfi-26) #:use-module (rnrs bytevectors) #:use-module (ice-9 i18n) #:use-module (ice-9 getopt-long) #:use-module (ice-9 suspendable-ports) #:use-module (ice-9 match) #:use-module (sxml simple) #:export ( make-client client? client-id client-key client-redirect-uri request serve-application ) #:declarative? #t) (define-record-type (make-client id key redirect-uri) client? (id client-id) (key client-key) (redirect-uri client-redirect-uri)) ;; subject is optional, if you don’t know who the user is. (define* (request client subject issuer #:key (http-request http-request)) ;; HACK: guile does not support other authentication schemes in ;; WWW-Authenticate than Basic, so it will crash when a response ;; containing that header will be issued. (declare-header! "WWW-Authenticate" (cute parse-header 'pragma <>) (lambda (value) (and (list? value) (let check-value ((schemes value)) (match schemes (() #t) (((hd . args) tl ...) (and (symbol? hd) (let check-args ((args args)) (match args (() #t) (((key . value) tl ...) (and (symbol? key) (string? value) (check-args tl))))) (check-value tl))))))) (cute write-header 'pragma <> <>)) ;; The same applies for the authorization header. (let ((original-parser (header-parser 'authorization)) (original-writer (header-writer 'authorization))) (declare-header! "Authorization" original-parser (lambda (value) #t) (match-lambda* ((('dpop . dpop) port) (format port "DPoP ~a" dpop)) ((value port) (original-writer value port))))) (match client (($ client-id client-key redirect-uri) (let ((do-login (let ((my-http-get (lambda* (uri . args) (apply http-request uri #:method 'GET args))) (my-http-post (lambda* (uri . args) (apply http-request uri #:method 'POST args)))) (match-lambda* ((subject issuer) (client:save-account (client:login subject issuer #:http-get my-http-get #:http-post my-http-post #:client-id client-id #:client-key client-key #:redirect-uri redirect-uri))) (($ subject issuer _ _ _ _) (client:save-account (client:login subject issuer #:http-get my-http-get #:http-post my-http-post #:client-id client-id #:client-key client-key #:redirect-uri redirect-uri))))))) (let ((current-account (do-login subject issuer))) (define (handle request request-body) (receive (response response-body) (let* ((access-token (client:account-access-token current-account)) (dpop-proof (issue-dpop-proof (client:account-keypair current-account) #:alg (case (kty client-key) ((EC) 'ES256) ((RSA) 'RS256)) #:htm (request-method request) #:htu (request-uri request) #:access-token access-token))) (let ((headers `((dpop . ,dpop-proof) (authorization . (dpop . ,access-token)) ,@(request-headers request)))) (http-request (request-uri request) #:method (request-method request) #:headers headers))) (if (eqv? (response-code response) 401) ;; Maybe the accesss token expired (let ((server-date (time-second (date->time-utc (response-date response)))) (exp (assq-ref (client:account-id-token current-account) 'exp))) (if (>= server-date exp) ;; The ID token expired, renew it. (begin (set! current-account (client:save-account (do-login (client:save-account (client:invalidate-access-token current-account))))) ;; Read it that way: invalidate the current ;; account access token, then save it so that ;; noone uses the invalid access token, then ;; try to log in again, and finally save the ;; new access token. (handle request request-body)) ;; The ID token has not expired, we don’t care. (values response response-body))) ;; OK or other error, we don’t care. (values response response-body)))) handle))))) (define* (serve-application id redirect-uri #:key (client-name "Example application") (client-uri "https://webid-oidc-demo.planete-kraus.eu")) (when (string? id) (set! id (string->uri id))) (when (string? redirect-uri) (set! redirect-uri (string->uri redirect-uri))) (when (string? client-uri) (set! client-uri (string->uri client-uri))) (let* ((manifest (format #f "{ \"@context\": \"https://www.w3.org/ns/solid/oidc-context.jsonld\", \"client_id\" : \"~a\", \"redirect_uris\" : [\"~a\"], \"client_name\" : \"~a\", \"client_uri\" : \"~a\", \"grant_types\" : [\"refresh_token\", \"authorization_code\"], \"response_types\" : [\"code\"] } " (uri->string id) (uri->string redirect-uri) client-name (uri->string id))) (manifest-etag (stubs:hash 'SHA-256 manifest))) (lambda (request request-body) (let ((uri (request-uri request))) (cond ((equal? (uri-path uri) (uri-path id)) (let ((if-none-match (request-if-none-match request))) (if (and (list? if-none-match) (member manifest-etag (map car (request-if-none-match request)))) (values (build-response #:code 304 #:reason-phrase "Not Modified" #:headers `((content-type application/ld+json) (etag . (,manifest-etag . #t)))) #f) (values (build-response #:headers `((content-type application/ld+json) (etag . (,manifest-etag . #t)) (cache-control public must-revalidate))) manifest)))) ((equal? (uri-path uri) (uri-path redirect-uri)) (let ((query-args (map (lambda (key=value) (let ((splits (map uri-decode (string-split key=value #\=)))) (if (or (null? splits) (null? (cdr splits))) splits (cons (string->symbol (car splits)) (cdr splits))))) (string-split (uri-query uri) #\&)))) (let ((code (assq-ref query-args 'code))) (if code (values (build-response #:headers `((content-type application/xhtml+xml))) (with-output-to-string (lambda () (sxml->xml `(*TOP* (*PI* xml "version=\"1.0\" encoding=\"utf-8\"") (html (@ (xmlns "http://www.w3.org/1999/xhtml") (xml:lang "en")) (head (title "Authorization")) (body (p "You have been authorized. Please paste the following code in the application:") (p (strong ,code))))))))) (values (build-response #:code 400 #:reason-phrase "Invalid Request" #:headers `((content-type application/xhtml+xml))) (with-output-to-string (lambda () (sxml->xml `(*TOP* (*PI* xml "version=\"1.0\" encoding=\"utf-8\"") (html (@ (xmlns "http://www.w3.org/1999/xhtml") (xml:lang "en")) (head (title "Error")) (body (p "Your identity provider did not authorize you. :(")))))))))))) (else (values (build-response #:code 404 #:reason-phrase "Not Found" #:headers `((content-type application/xhtml+xml))) (with-output-to-string (lambda () (sxml->xml `(*TOP* (*PI* xml "version=\"1.0\" encoding=\"utf-8\"") (html (@ (xmlns "http://www.w3.org/1999/xhtml") (xml:lang "en")) (head (title "Not Found")) (body (p "This page does not exist on the server."))))))))))))))