;; 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)
;; Better for syntax highlighting
(define client:)
(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."))))))))))))))