;; 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-id-token)
#:use-module (webid-oidc dpop-proof)
#:use-module (webid-oidc jwk)
#:use-module (webid-oidc client-manifest)
#:use-module (webid-oidc web-i18n)
#: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 cache) #:prefix cache:)
#:use-module ((webid-oidc client accounts) #:prefix account:)
#:use-module ((webid-oidc client client) #:prefix client:)
#:use-module (web uri)
#: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)
#:use-module (oop goops)
#:duplicates (merge-generics)
#:re-export
(
(client: . )
(client:client-id . client-id)
(client:key-pair . key-pair)
(client:redirect-uri . redirect-uri)
(client:client . client)
(account:authorization-process . authorization-process)
(account:authorization-state . authorization-state)
)
#:export
(
request
serve-application
)
#:declarative? #t)
;; For syntax highlighting
(define account:)
(define client:)
(define (setup-headers!)
;; 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))))))
(define* (initial-login client issuer)
(setup-headers!)
(parameterize ((client:client client))
(make
#:issuer issuer)))
(define (request account uri . other-args)
(setup-headers!)
(unless (account:access-token account)
(set! account (account:refresh account)))
(define (do-with-headers method headers non-header-args can-fail?)
(let* ((access-token (account:access-token account))
(dpop-proof
(let ((key-pair (account:key-pair account)))
(issue
key-pair
#:jwk (public-key key-pair)
#:htm method
#:htu uri
#:access-token access-token))))
(let ((all-headers
`((dpop . ,dpop-proof)
(authorization . (dpop . ,access-token))
(accept-language . ((2 . ,(G_ "accept-language-header|en-us"))
(1 . "en-us")))
,@headers)))
(receive (response body)
(apply (p:anonymous-http-request) uri
#:headers all-headers
non-header-args)
(let ((code (response-code response)))
(if (and (eqv? code 401) can-fail?)
;; Code expired
(begin
(set! account (account:refresh (account:invalidate-access-token account)))
;; retry
(do-with-headers method headers non-header-args #f))
(values account response body)))))))
(let scan-arguments ((args other-args)
(headers #f)
(non-header-args '())
(method #f))
(match args
(()
(cond
((not headers)
(scan-arguments args '() non-header-args method))
((not method)
(scan-arguments args headers non-header-args 'GET))
(else
(do-with-headers method headers (reverse non-header-args) #t))))
((#:method new-method args ...)
(scan-arguments args headers non-header-args (or method new-method)))
((#:headers (new-headers ...) args ...)
(scan-arguments args (or headers new-headers) non-header-args method))
((kw value args ...)
(scan-arguments args headers `(,value ,kw ,@non-header-args) method)))))
(define-class ()
(client-name #:init-keyword #:client-name #:accessor client-name)
(client-uri #:init-keyword #:client-uri #:accessor client-uri)
(grant-types #:init-keyword #:grant-types #:accessor grant-types)
(response-types #:init-keyword #:response-types #:accessor response-types)
#:module-name '(webid-oidc client))
(define-method (initialize (client ) initargs)
(next-method)
(let-keywords
initargs #t
((client-name (G_ "Example application"))
(client-uri "https://webid-oidc-demo.planete-kraus.eu")
(grant-types '(refresh_token authorization_code))
(response-types '(code)))
(let fix-grant-types ((grant-types grant-types)
(ok '()))
(match grant-types
(()
(let ((grant-types (reverse ok)))
(let fix-response-types ((response-types response-types)
(ok '()))
(match response-types
(()
(let ((response-types (reverse ok)))
(let fix-client-uri ((client-uri client-uri))
(match client-uri
((? uri? client-uri)
(let fix-client-name ((client-name client-name))
(match client-name
((? string? client-name)
(begin
(slot-set! client 'client-name client-name)
(slot-set! client 'client-uri client-uri)
(slot-set! client 'grant-types grant-types)
(slot-set! client 'response-types response-types)))
(else
(scm-error 'wrong-type-arg "make"
(G_ "#:client-name should be a string")
'()
(list client-name))))))
((? string? (= string->uri (? uri? client-uri)))
(fix-client-uri client-uri))
(else
(scm-error 'wrong-type-arg "make"
(G_ "#:client-uri should be an URI")
'()
(list client-uri)))))))
(((or (? string? (= string->symbol hd))
(? symbol? hd))
response-types ...)
(fix-response-types response-types `(,hd ,@ok)))
(else
(scm-error 'wrong-type-arg "make"
(G_ "#:response-types should be a list of symbols")
'()
(list response-types)))))))
(((or (? string? (= string->symbol hd))
(? symbol? hd))
grant-types ...)
(fix-grant-types grant-types `(,hd ,@ok)))
(else
(scm-error 'wrong-type-arg "make"
(G_ "#:grant-types should be a list of symbols")
'()
(list grant-types)))))))
(define-method (->json-data (client ))
(let ((other
(catch 'goops-error
(lambda ()
(next-method))
(lambda _
'()))))
(let ((all
`((client_name . ,(client-name client))
(client_uri . ,(uri->string (client-uri client)))
(grant_types . ,(list->vector (map symbol->string (grant-types client))))
(response_types . ,(list->vector (map symbol->string (response-types client))))
,@other)))
;; Put @context first
(receive (context non-context)
(let search-context ((fields all)
(context-ones '())
(non-context-ones '()))
(match fields
((('@context . ,context) fields ...)
(search-context fields `(,context ,@context-ones) non-context-ones))
((non-context fields ...)
(search-context fields context-ones `(,non-context ,@non-context-ones)))
(()
(values (reverse context-ones) (reverse non-context-ones)))))
(append
(map (lambda (ctx) `(@context . ,ctx)) context)
non-context)))))
(define* (serve-application id redirect-uri . args)
(let ((manifest (apply make
#:client-id id
#:redirect-uris (list redirect-uri)
args)))
(lambda (request request-body)
(parameterize ((web-locale request))
(let ((uri (request-uri request)))
(cond
((equal? (uri-path uri) (uri-path id))
(receive (response response-body) (serve manifest #f)
(let ((if-none-match (request-if-none-match request))
(etag (response-etag response)))
(if (and (list? if-none-match)
etag
(member (car etag) (map car if-none-match)))
(values
(build-response
#:code 304
#:reason-phrase (W_ "reason-phrase|Not Modified")
#:headers `((content-type application/ld+json)
(etag . ,etag)))
#f)
(values response response-body)))))
((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 ,(W_ "xml-lang|en")))
(head
(title ,(W_ "page-title|Authorization")))
(body
(p ,(W_ "You have been authorized. Please paste the following code in the application:"))
(p (strong ,code)))))))))
(values
(build-response
#:code 400
#:reason-phrase (W_ "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 ,(W_ "xml-lang|en")))
(head
(title ,(W_ "page-title|Error")))
(body
(p ,(W_ "Your identity provider did not authorize you. :(")))))))))))))
(else
(values
(build-response
#:code 404
#:reason-phrase (W_ "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 ,(W_ "xml-lang|en")))
(head
(title ,(W_ "page-title|Not Found")))
(body
(p ,(W_ "This page does not exist on the server."))))))))))))))))