187 lines
7.6 KiB
Common Lisp
187 lines
7.6 KiB
Common Lisp
;;;; cl-scopes/web/jwt - JWT creation and validation
|
|
;;;; based on: cljwt
|
|
|
|
(defpackage :scopes/web/jwt
|
|
(:use :common-lisp)
|
|
(:local-nicknames (:alx :alexandria)
|
|
(:b64 :cl-base64)
|
|
(:fxs :flexi-streams)
|
|
(:ic :ironclad)
|
|
(:jzon :com.inuoe.jzon))
|
|
(:export #:issue #:decode
|
|
#:unsecured-token #:invalid-hmac
|
|
#:unsupported-algorithm #:invalid-time #:expired #:not-yet-valid))
|
|
|
|
(in-package :scopes/web/jwt)
|
|
|
|
(defconstant *unix-time-base* (encode-universal-time 0 0 0 1 1 1970 0))
|
|
|
|
(defmacro bind-hash-tables (bindings &body body)
|
|
`(let ,(loop for binding in bindings collect
|
|
(list (car binding)
|
|
`(etypecase ,(cadr binding)
|
|
(hash-table ,(cadr binding))
|
|
(list (plist-hash-table ,(cadr binding)
|
|
:test #'equal)))))
|
|
,@body))
|
|
|
|
(defmacro add-claims (hash &rest claims)
|
|
`(progn ,@(loop for (key value) on claims by #'cddr collect
|
|
`(when ,value
|
|
(setf (gethash ,key ,hash) ,value)))))
|
|
|
|
(defun to-unix-time (time)
|
|
(when time (- time *unix-time-base*)))
|
|
|
|
(defun from-unix-time (time)
|
|
(when time (+ time *unix-time-base*)))
|
|
|
|
(defun base64-encode (input)
|
|
"Takes a string or octets, returns an unpadded URI-encoded Base64 string."
|
|
(etypecase input
|
|
(string (base64-encode (fxs:string-to-octets input :external-format :utf-8)))
|
|
((simple-array (unsigned-byte 8))
|
|
(with-output-to-string (out)
|
|
(with-input-from-string (in (b64:usb8-array-to-base64-string input :uri t))
|
|
(loop for character = (read-char in nil)
|
|
while character do
|
|
;; CL-BASE64 always uses padding, which must be removed.
|
|
(unless (eq character #\.)
|
|
(write-char character out))))))))
|
|
|
|
(defun base64-decode (base-64-string)
|
|
"Takes a base64-uri string and return an array of octets"
|
|
(b64:base64-string-to-usb8-array
|
|
;; Re-pad the string, or CL-BASE64 will get confused
|
|
(concatenate 'string
|
|
base-64-string
|
|
(make-array (rem (length base-64-string) 4)
|
|
:element-type 'character
|
|
:initial-element #\.))
|
|
:uri t))
|
|
|
|
(defun issue (claims &key algorithm secret issuer subject audience
|
|
expiration not-before issued-at id more-header)
|
|
"Encodes and returns a JSON Web Token. Times are in universal-time,
|
|
number of seconds from 1900-01-01 00:00:00"
|
|
(bind-hash-tables ((claimset claims)
|
|
(header more-header))
|
|
;; Add registered claims to the claims hash table
|
|
(add-claims claimset
|
|
"iss" issuer
|
|
"sub" subject
|
|
"aud" audience
|
|
"exp" (to-unix-time expiration)
|
|
"nbf" (to-unix-time not-before)
|
|
"iat" (to-unix-time issued-at)
|
|
"jti" id)
|
|
;; Add type and algorithm to the header hash table
|
|
(add-claims header
|
|
"typ" "JWT"
|
|
"alg" (ecase algorithm
|
|
(:none "none")
|
|
(:hs256 "HS256")))
|
|
;; Prepare JSON
|
|
(let ((header-string (base64-encode
|
|
(with-output-to-string (s)
|
|
(jzon:stringify header s))))
|
|
(claims-string (base64-encode
|
|
(with-output-to-string (s)
|
|
(jzon:stringify claimset s)))))
|
|
;; Assemble and, if applicable, sign the JWT
|
|
(format nil "~A.~A.~@[~A~]"
|
|
header-string
|
|
claims-string
|
|
(when (eq algorithm :hs256)
|
|
(HS256-digest header-string
|
|
claims-string
|
|
secret))))))
|
|
|
|
(defun HS256-digest (header-string claims-string secret)
|
|
"Takes header and claims in Base64, secret as a string or octets,
|
|
returns the digest, in Base64"
|
|
(base64-encode
|
|
(ic:hmac-digest
|
|
(ic:update-hmac
|
|
(ic:make-hmac (etypecase secret
|
|
((simple-array (unsigned-byte 8))
|
|
secret)
|
|
(string
|
|
(string-to-octets secret
|
|
:external-format :utf-8)))
|
|
'ironclad:SHA256)
|
|
(concatenate '(vector (unsigned-byte 8))
|
|
(string-to-octets
|
|
header-string)
|
|
#(46) ; ASCII period (.)
|
|
(string-to-octets
|
|
claims-string))))))
|
|
|
|
(defun compare-HS256-digest (header-string claims-string
|
|
secret reported-digest)
|
|
"Takes header and claims in Base64, secret as a string or octets, and a digest in Base64 to compare with. Signals an error if there is a mismatch."
|
|
(let ((computed-digest
|
|
(HS256-digest header-string
|
|
claims-string
|
|
secret)))
|
|
(unless (equalp computed-digest
|
|
reported-digest)
|
|
(cerror "Continue anyway" 'invalid-hmac
|
|
:reported-digest reported-digest
|
|
:computed-digest computed-digest))))
|
|
|
|
(defun decode (jwt-string &key secret fail-if-unsecured)
|
|
"Decodes and verifies a JSON Web Token. Returns two hash tables,
|
|
token claims and token header"
|
|
(destructuring-bind (header-string claims-string digest-string)
|
|
(str:split #\. jwt-string)
|
|
(let* ((header-hash (jzon:parse (octets-to-string (base64-decode header-string)
|
|
:external-format :utf-8)))
|
|
(claims-hash (jzon:parse (octets-to-string (base64-decode claims-string)
|
|
:external-format :utf-8)))
|
|
(algorithm (gethash "alg" header-hash)))
|
|
;; Verify HMAC
|
|
(cond ((equal algorithm "HS256")
|
|
(compare-HS256-digest header-string
|
|
claims-string
|
|
secret
|
|
digest-string))
|
|
((and (or (null algorithm) (equal algorithm "none")) fail-if-unsecured)
|
|
(cerror "Continue anyway" 'unsecured-token))
|
|
(t (cerror "Continue anyway" 'unsupported-algorithm
|
|
:algorithm algorithm)))
|
|
;; Verify timestamps
|
|
(let ((expires (from-unix-time (gethash "exp" claims-hash)))
|
|
(not-before (from-unix-time (gethash "nbf" claims-hash)))
|
|
(current-time (get-universal-time)))
|
|
(when (and expires (> current-time expires))
|
|
(cerror "Continue anyway" 'expired :delta (- current-time expires)))
|
|
(when (and not-before (< current-time not-before))
|
|
(cerror "Continue anyway" 'not-yet-valid :delta (- current-time not-before))))
|
|
;; Return hashes
|
|
(values claims-hash header-hash))))
|
|
|
|
;;; Conditions
|
|
|
|
(define-condition unsecured-token (error) ())
|
|
|
|
(define-condition invalid-hmac (error) ())
|
|
|
|
(define-condition unsupported-algorithm (error)
|
|
((algorithm :initarg :algorithm :reader algorithm))
|
|
(:report (lambda (condition stream)
|
|
(format stream "Algorithm \"~A\" not supported"
|
|
(algorithm condition)))))
|
|
|
|
(define-condition invalid-time (error)
|
|
((delta :initarg :delta :reader time-delta))
|
|
(:report (lambda (condition stream)
|
|
(format stream "Token ~A. ~D seconds off."
|
|
(typecase condition
|
|
(expired "has expired")
|
|
(not-yet-valid "is not yet valid"))
|
|
(time-delta condition)))))
|
|
|
|
(define-condition expired (invalid-time) ())
|
|
|
|
(define-condition not-yet-valid (invalid-time) ())
|