Commit59e67b56Recorded25 Mar 2026Repositorysigil-oauth
Implement OAuth 2.0 client library
Message
Three modules: - (sigil oauth): Config/token records, PKCE generation, authorization URL building, token exchange, token refresh, auto-refresh, and provider presets for Google and Twitch. - (sigil oauth store): Persistent JSON token storage under ~/.config/sigil/<service>/tokens.json with save/load/clear. - (sigil oauth server): Local HTTP callback server for CLI-friendly OAuth flows — captures redirect, exchanges code, returns tokens.
39 tests covering base64url encoding, PKCE, authorization URLs, token parsing, expiry checking, provider presets, and token storage round-trips.
Changed
.gitignore | 2 +
README.md | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
src/sigil/oauth.sgl | 387 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/oauth/server.sgl | 203 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/oauth/store.sgl | 115 +++++++++++++++++++++++++++++++++++++++++++++++++
tests/oauth-test.sgl | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
tests/store-test.sgl | 110 +++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 1252 insertions(+), 1 deletion(-)Diff
.gitignoreadded
@@ -0,0 +1,2 @@
+1
build/+2
libREADME.mdmodified
@@ -1,3 +1,152 @@
1
# sigil-oauth 2
−3
OAuth 2.0 client library for Sigil — shared auth for YouTube, Twitch, Wise, and other API clients 3
No newline at end of file+4
OAuth 2.0 client library for Sigil — shared auth for YouTube, Twitch, Wise, and other API clients.+5
+6
## Modules+7
+8
### `(sigil oauth)` — Core OAuth2 client+9
+10
Records, authorization URL generation with PKCE, token exchange, token refresh, token validation, and provider presets.+11
+12
### `(sigil oauth store)` — Token persistence+13
+14
Save and load tokens as JSON files under `~/.config/sigil/<service>/tokens.json`.+15
+16
### `(sigil oauth server)` — Local callback server+17
+18
Temporary localhost HTTP server that captures the OAuth redirect, exchanges the authorization code for tokens, and shuts down.+19
+20
## Quick Start+21
+22
```scheme+23
(import (sigil oauth)+24
(sigil oauth store)+25
(sigil oauth server))+26
+27
;; 1. Create a provider config+28
(define config+29
(oauth-google-config+30
"your-client-id"+31
"your-client-secret"+32
'("https://www.googleapis.com/auth/youtube")))+33
+34
;; 2. Run the full authorization flow (opens browser, waits for callback)+35
(define tokens+36
(oauth-run-authorization-flow config (current-second)))+37
+38
;; 3. Save tokens for later+39
(oauth-save-tokens "youtube" tokens)+40
+41
;; 4. Load tokens in a future session+42
(define tokens (oauth-load-tokens "youtube"))+43
+44
;; 5. Ensure token is valid before making API calls (auto-refreshes if expired)+45
(define tokens (oauth-ensure-valid-token config tokens (current-second)))+46
```+47
+48
## Manual Flow+49
+50
For more control over the authorization flow:+51
+52
```scheme+53
;; Generate state and PKCE+54
(define state (oauth-generate-state))+55
(define pkce (oauth-generate-pkce))+56
+57
;; Build the authorization URL+58
(define auth-url+59
(oauth-authorization-url config state #{ pkce: pkce }))+60
+61
;; Display URL for user to visit...+62
;; Then start the callback server to capture the redirect:+63
(define tokens+64
(oauth-start-callback-server config state (current-second)+65
#{ pkce-verifier: (oauth-pkce-verifier pkce) }))+66
```+67
+68
## Provider Presets+69
+70
### Google / YouTube+71
+72
```scheme+73
(oauth-google-config client-id client-secret scopes)+74
(oauth-google-config client-id client-secret scopes #{ redirect-uri: "..." })+75
```+76
+77
Sets `access_type=offline` and `prompt=consent` automatically to ensure refresh tokens are issued.+78
+79
### Twitch+80
+81
```scheme+82
(oauth-twitch-config client-id client-secret scopes)+83
(oauth-twitch-config client-id client-secret scopes #{ redirect-uri: "..." })+84
```+85
+86
### Custom Provider+87
+88
```scheme+89
(oauth-config+90
client-id: "..."+91
client-secret: "..."+92
auth-url: "https://provider.com/oauth2/authorize"+93
token-url: "https://provider.com/oauth2/token"+94
scopes: '("scope1" "scope2")+95
redirect-uri: "http://localhost:8085/callback"+96
extra-params: #{ custom_param: "value" })+97
```+98
+99
## Token Management+100
+101
```scheme+102
;; Check if a token has expired (with 60s grace period)+103
(oauth-token-expired? tokens (current-second))+104
+105
;; Custom grace period (300 seconds)+106
(oauth-token-expired? tokens (current-second) 300)+107
+108
;; Refresh manually+109
(define new-tokens+110
(oauth-refresh-token config (oauth-tokens-refresh-token tokens) (current-second)))+111
+112
;; Auto-refresh: returns existing tokens if valid, refreshes if expired+113
(define valid-tokens+114
(oauth-ensure-valid-token config tokens (current-second)))+115
+116
;; Validate against a provider endpoint+117
(oauth-validate-token tokens "https://id.twitch.tv/oauth2/validate")+118
```+119
+120
## Token Storage+121
+122
Tokens are stored as JSON in `~/.config/sigil/<service-name>/tokens.json`:+123
+124
```scheme+125
(oauth-save-tokens "youtube" tokens) ;; default path+126
(oauth-save-tokens "youtube" tokens "/custom/path.json") ;; custom path+127
+128
(oauth-load-tokens "youtube") ;; returns oauth-tokens or #f+129
(oauth-clear-tokens "youtube") ;; returns #t if deleted, #f if missing+130
```+131
+132
## Building+133
+134
```bash+135
sigil build --redirects dev-redirects.sgl+136
```+137
+138
## Testing+139
+140
```bash+141
sigil build --redirects dev-redirects.sgl+142
ln -sf build/dev/lib lib # one-time setup+143
sigil test --redirects dev-redirects.sgl+144
```+145
+146
## Dependencies+147
+148
- sigil-stdlib+149
- sigil-tls+150
- sigil-http+151
- sigil-json+152
- sigil-crypto+153
- sigil-logsrc/sigil/oauth.sgladded
@@ -0,0 +1,387 @@
+1
;;; (sigil oauth) - OAuth 2.0 client library for Sigil.+2
;;;+3
;;; Provides records, authorization URL generation with PKCE,+4
;;; token exchange, token refresh, token validation, and+5
;;; auto-refresh helpers. Provider presets for Google and Twitch.+6
;;;+7
;;; Designed to be shared across sigil-youtube, sigil-twitch,+8
;;; sigil-wise, and other API client libraries.+9
+10
(define-library (sigil oauth)+11
(import (sigil core)+12
(only (scheme base) string->utf8)+13
(sigil dict)+14
(sigil string)+15
(sigil struct)+16
(sigil json)+17
(sigil crypto)+18
(sigil http client)+19
(sigil http request)+20
(sigil log))+21
+22
(export ;; Config record+23
oauth-config+24
oauth-config?+25
oauth-config-client-id+26
oauth-config-client-secret+27
oauth-config-auth-url+28
oauth-config-token-url+29
oauth-config-scopes+30
oauth-config-redirect-uri+31
oauth-config-extra-params+32
+33
;; Tokens record+34
oauth-tokens+35
oauth-tokens?+36
oauth-tokens-access-token+37
oauth-tokens-refresh-token+38
oauth-tokens-expires-at+39
oauth-tokens-token-type+40
oauth-tokens-scopes+41
+42
;; PKCE record+43
oauth-pkce+44
oauth-pkce?+45
oauth-pkce-verifier+46
oauth-pkce-challenge+47
oauth-pkce-method+48
+49
;; Authorization+50
oauth-generate-state+51
oauth-generate-pkce+52
oauth-authorization-url+53
+54
;; Token exchange+55
oauth-exchange-code+56
oauth-refresh-token+57
+58
;; Token validation+59
oauth-token-expired?+60
oauth-validate-token+61
oauth-ensure-valid-token+62
+63
;; Provider presets+64
oauth-google-config+65
oauth-twitch-config+66
+67
;; Utilities+68
base64url-encode+69
parse-token-response)+70
+71
(begin+72
+73
;; ---------------------------------------------------------------+74
;; Records+75
;; ---------------------------------------------------------------+76
+77
(define-struct oauth-config+78
(client-id)+79
(client-secret default: #f)+80
(auth-url)+81
(token-url)+82
(scopes default: '())+83
(redirect-uri default: "http://localhost:8085/callback")+84
(extra-params default: #{}))+85
+86
(define-struct oauth-tokens+87
(access-token)+88
(refresh-token default: #f)+89
(expires-at default: #f)+90
(token-type default: "Bearer")+91
(scopes default: '()))+92
+93
(define-struct oauth-pkce+94
(verifier)+95
(challenge)+96
(method default: "S256"))+97
+98
;; ---------------------------------------------------------------+99
;; Base64url encoding (RFC 7636 / RFC 4648 §5)+100
;; ---------------------------------------------------------------+101
+102
;;; Encode a bytevector as base64url without padding.+103
;;; Standard base64, then replace + with -, / with _, strip =.+104
(define (base64url-encode bv)+105
(let ((b64 (base64-encode bv)))+106
(let loop ((i 0) (acc '()))+107
(if (>= i (string-length b64))+108
(list->string (reverse acc))+109
(let ((c (string-ref b64 i)))+110
(cond+111
((char=? c #\+) (loop (+ i 1) (cons #\- acc)))+112
((char=? c #\/) (loop (+ i 1) (cons #\_ acc)))+113
((char=? c #\=) (loop (+ i 1) acc))+114
(else (loop (+ i 1) (cons c acc)))))))))+115
+116
;; ---------------------------------------------------------------+117
;; PKCE (RFC 7636)+118
;; ---------------------------------------------------------------+119
+120
;;; Generate a cryptographically random state parameter.+121
;;; Returns a 32-byte base64url-encoded string.+122
(define (oauth-generate-state)+123
(base64url-encode (random-bytes 32)))+124
+125
;;; Generate a PKCE code verifier and challenge pair.+126
;;; verifier: 32 random bytes, base64url-encoded (43 chars)+127
;;; challenge: SHA256(verifier), base64url-encoded+128
(define (oauth-generate-pkce)+129
(let* ((verifier (base64url-encode (random-bytes 32)))+130
(challenge (base64url-encode+131
(sha256-bytes (string->utf8 verifier)))))+132
(oauth-pkce+133
verifier: verifier+134
challenge: challenge+135
method: "S256")))+136
+137
;; ---------------------------------------------------------------+138
;; Authorization URL+139
;; ---------------------------------------------------------------+140
+141
;;; Build the OAuth2 authorization redirect URL.+142
;;;+143
;;; config: oauth-config record+144
;;; state: CSRF protection string (use oauth-generate-state)+145
;;; Optional keyword arguments via opts dict:+146
;;; pkce: oauth-pkce record to include code_challenge+147
;;; extra-params: dict of additional query params+148
(define (oauth-authorization-url config state . rest)+149
(let ((opts (if (null? rest) #{} (car rest))))+150
(let* ((pkce (dict-ref opts pkce: #f))+151
(extra (dict-ref opts extra-params: #{}))+152
(scopes (oauth-config-scopes config))+153
(scope-str (if (null? scopes)+154
#f+155
(string-join scopes " ")))+156
(params (list+157
(cons "response_type" "code")+158
(cons "client_id" (oauth-config-client-id config))+159
(cons "redirect_uri" (oauth-config-redirect-uri config))+160
(cons "state" state)+161
(cons "scope" scope-str)))+162
;; Add PKCE params if present+163
(params (if pkce+164
(append params+165
(list+166
(cons "code_challenge"+167
(oauth-pkce-challenge pkce))+168
(cons "code_challenge_method"+169
(oauth-pkce-method pkce))))+170
params))+171
;; Add config-level extra params+172
(params (append params+173
(map (lambda (k)+174
(cons (keyword->string k)+175
(dict-ref (oauth-config-extra-params config) k)))+176
(dict-keys (oauth-config-extra-params config)))))+177
;; Add call-level extra params+178
(params (append params+179
(map (lambda (k)+180
(cons (keyword->string k)+181
(dict-ref extra k)))+182
(dict-keys extra)))))+183
(string-append+184
(oauth-config-auth-url config)+185
(build-query-string params)))))+186
+187
;; ---------------------------------------------------------------+188
;; Token exchange helpers+189
;; ---------------------------------------------------------------+190
+191
;;; Build a form-urlencoded POST body from an alist.+192
;;; Reuses build-query-string from (sigil http request), stripping the "?" prefix.+193
(define (build-form-body params)+194
(let ((qs (build-query-string params)))+195
(if (string=? qs "")+196
""+197
(substring qs 1 (string-length qs)))))+198
+199
;;; POST to a token endpoint with form-urlencoded body.+200
;;; Returns parsed JSON response or raises an error.+201
(define (token-post url params)+202
(let* ((body (build-form-body params))+203
(response (http-post url body+204
headers: #{ content-type:+205
"application/x-www-form-urlencoded" })))+206
(if (not (http-response? response))+207
(error "OAuth token request failed: no response"))+208
(let ((status (http-response-status response))+209
(resp-body (http-response-body response)))+210
(cond+211
((>= status 400)+212
(error (string-append+213
"OAuth token request failed ("+214
(number->string status) "): "+215
(or resp-body ""))))+216
(else+217
(if (and resp-body (not (string=? resp-body "")))+218
(json-decode resp-body)+219
(error "OAuth token request returned empty response")))))))+220
+221
;;; Parse a token endpoint JSON response into an oauth-tokens record.+222
;;; expires-at is computed from expires_in (seconds from now).+223
(define (parse-token-response data current-time)+224
(let* ((expires-in (dict-ref data expires_in: #f))+225
(expires-at (if expires-in+226
(+ current-time expires-in)+227
#f))+228
(scope-str (dict-ref data scope: #f))+229
(scopes (if scope-str+230
(string-split scope-str " ")+231
'())))+232
(oauth-tokens+233
access-token: (dict-ref data access_token:)+234
refresh-token: (dict-ref data refresh_token: #f)+235
expires-at: expires-at+236
token-type: (dict-ref data token_type: "Bearer")+237
scopes: scopes)))+238
+239
;; ---------------------------------------------------------------+240
;; Token exchange+241
;; ---------------------------------------------------------------+242
+243
;;; Exchange an authorization code for tokens.+244
;;;+245
;;; config: oauth-config record+246
;;; code: the authorization code from the callback+247
;;; current-time: current unix timestamp (from current-second)+248
;;; Optional keyword arguments via opts dict:+249
;;; pkce-verifier: the PKCE code_verifier string+250
(define (oauth-exchange-code config code current-time . rest)+251
(let ((opts (if (null? rest) #{} (car rest))))+252
(let* ((verifier (dict-ref opts pkce-verifier: #f))+253
(params (list+254
(cons "grant_type" "authorization_code")+255
(cons "code" code)+256
(cons "client_id" (oauth-config-client-id config))+257
(cons "client_secret"+258
(oauth-config-client-secret config))+259
(cons "redirect_uri"+260
(oauth-config-redirect-uri config))+261
(cons "code_verifier" verifier)))+262
(data (token-post (oauth-config-token-url config) params)))+263
(parse-token-response data current-time))))+264
+265
;;; Refresh an access token using a refresh token.+266
;;;+267
;;; config: oauth-config record+268
;;; refresh-tok: the refresh token string+269
;;; current-time: current unix timestamp+270
;;;+271
;;; Returns a new oauth-tokens record. The new record may or may+272
;;; not include a new refresh token depending on the provider.+273
;;; If the provider doesn't return a new refresh_token, the original+274
;;; is preserved in the returned record.+275
(define (oauth-refresh-token config refresh-tok current-time)+276
(let* ((params (list+277
(cons "grant_type" "refresh_token")+278
(cons "refresh_token" refresh-tok)+279
(cons "client_id" (oauth-config-client-id config))+280
(cons "client_secret"+281
(oauth-config-client-secret config))))+282
(data (token-post (oauth-config-token-url config) params))+283
(tokens (parse-token-response data current-time)))+284
;; Preserve original refresh token if provider didn't send a new one+285
(if (not (oauth-tokens-refresh-token tokens))+286
(oauth-tokens+287
access-token: (oauth-tokens-access-token tokens)+288
refresh-token: refresh-tok+289
expires-at: (oauth-tokens-expires-at tokens)+290
token-type: (oauth-tokens-token-type tokens)+291
scopes: (oauth-tokens-scopes tokens))+292
tokens)))+293
+294
;; ---------------------------------------------------------------+295
;; Token validation+296
;; ---------------------------------------------------------------+297
+298
;;; Check if a token has expired (or will expire within a grace period).+299
;;; grace-seconds defaults to 60 (refresh 1 minute before expiry).+300
(define (oauth-token-expired? tokens current-time . rest)+301
(let ((grace (if (null? rest) 60 (car rest))))+302
(let ((expires-at (oauth-tokens-expires-at tokens)))+303
(if (not expires-at)+304
#f ;; No expiry info — assume valid+305
(>= current-time (- expires-at grace))))))+306
+307
;;; Validate a token against a provider's validation endpoint.+308
;;; validation-url: the provider's token info/validation URL+309
;;; Returns the parsed JSON response or #f if invalid.+310
(define (oauth-validate-token tokens validation-url)+311
(let* ((url (string-append validation-url+312
"?access_token="+313
(url-encode-value+314
(oauth-tokens-access-token tokens))))+315
(response (http-get url)))+316
(if (not (http-response? response))+317
#f+318
(let ((status (http-response-status response)))+319
(if (>= status 400)+320
#f+321
(let ((body (http-response-body response)))+322
(if (and body (not (string=? body "")))+323
(json-decode body)+324
#t)))))))+325
+326
;;; Ensure the token is valid, refreshing if expired.+327
;;; Returns a (possibly new) oauth-tokens record.+328
;;;+329
;;; config: oauth-config record+330
;;; tokens: current oauth-tokens record+331
;;; current-time: current unix timestamp+332
;;;+333
;;; Raises an error if refresh fails or no refresh token is available.+334
(define (oauth-ensure-valid-token config tokens current-time)+335
(if (not (oauth-token-expired? tokens current-time))+336
tokens+337
(let ((refresh-tok (oauth-tokens-refresh-token tokens)))+338
(if (not refresh-tok)+339
(error "OAuth token expired and no refresh token available")+340
(begin+341
(log-info "OAuth token expired, refreshing...")+342
(oauth-refresh-token config refresh-tok current-time))))))+343
+344
;; ---------------------------------------------------------------+345
;; Provider presets+346
;; ---------------------------------------------------------------+347
+348
;;; Create a provider-specific oauth-config with preset URLs.+349
(define (make-provider-config client-id client-secret scopes+350
auth-url token-url extra-params opts)+351
(let ((redirect (dict-ref opts redirect-uri: #f)))+352
(let ((config (oauth-config+353
client-id: client-id+354
client-secret: client-secret+355
auth-url: auth-url+356
token-url: token-url+357
scopes: scopes+358
extra-params: extra-params)))+359
(if redirect+360
(oauth-config+361
client-id: client-id+362
client-secret: client-secret+363
auth-url: auth-url+364
token-url: token-url+365
scopes: scopes+366
redirect-uri: redirect+367
extra-params: extra-params)+368
config))))+369
+370
;;; Create an oauth-config for Google/YouTube OAuth2.+371
;;; Google requires access_type=offline for refresh tokens.+372
(define (oauth-google-config client-id client-secret scopes . rest)+373
(make-provider-config client-id client-secret scopes+374
"https://accounts.google.com/o/oauth2/v2/auth"+375
"https://oauth2.googleapis.com/token"+376
#{ access_type: "offline" prompt: "consent" }+377
(if (null? rest) #{} (car rest))))+378
+379
;;; Create an oauth-config for Twitch OAuth2.+380
(define (oauth-twitch-config client-id client-secret scopes . rest)+381
(make-provider-config client-id client-secret scopes+382
"https://id.twitch.tv/oauth2/authorize"+383
"https://id.twitch.tv/oauth2/token"+384
#{}+385
(if (null? rest) #{} (car rest))))+386
+387
))src/sigil/oauth/server.sgladded
@@ -0,0 +1,203 @@
+1
;;; (sigil oauth server) - Local HTTP callback server for OAuth flow.+2
;;;+3
;;; Starts a temporary localhost HTTP server that listens for the OAuth+4
;;; redirect callback, extracts the authorization code, exchanges it+5
;;; for tokens, and returns them.+6
;;;+7
;;; Enables a CLI-friendly OAuth flow:+8
;;; 1. Generate authorization URL+9
;;; 2. Open browser (user authorizes)+10
;;; 3. Provider redirects to localhost+11
;;; 4. Server captures code, exchanges for tokens+12
;;; 5. Server shuts down, returns tokens+13
+14
(define-library (sigil oauth server)+15
(import (sigil core)+16
(sigil dict)+17
(sigil string)+18
(sigil struct)+19
(sigil json)+20
(sigil oauth)+21
(sigil http server)+22
(sigil http request)+23
(sigil http response)+24
(sigil log))+25
+26
(export oauth-start-callback-server+27
oauth-run-authorization-flow)+28
+29
(begin+30
+31
;; ---------------------------------------------------------------+32
;; HTML response templates+33
;; ---------------------------------------------------------------+34
+35
(define html-prefix+36
(string-append+37
"<!DOCTYPE html><html><head>"+38
"<style>body{font-family:sans-serif;display:flex;justify-content:center;"+39
"align-items:center;min-height:100vh;margin:0;background:#f5f5f5;}"+40
".card{background:white;padding:2rem;border-radius:8px;"+41
"box-shadow:0 2px 8px rgba(0,0,0,0.1);text-align:center;max-width:400px;}"+42
"p{color:#555;}</style></head><body><div class='card'>"))+43
+44
(define html-suffix "</div></body></html>")+45
+46
(define (make-response-html title color message)+47
(string-append html-prefix+48
"<h1 style='color:" color ";margin-bottom:0.5rem;'>" title "</h1>"+49
"<p>" message "</p>"+50
html-suffix))+51
+52
(define success-html+53
(make-response-html "Authorization Successful" "#2d7d46"+54
"You can close this browser tab and return to the terminal."))+55
+56
;; ---------------------------------------------------------------+57
;; Callback server+58
;; ---------------------------------------------------------------+59
+60
;;; Start a temporary HTTP server that waits for the OAuth callback.+61
;;;+62
;;; config: oauth-config record+63
;;; expected-state: the state parameter sent in the authorization URL+64
;;; current-time: current unix timestamp+65
;;;+66
;;; Optional keyword args via opts dict:+67
;;; port: port number (default: 8085)+68
;;; pkce-verifier: PKCE code_verifier for the token exchange+69
;;;+70
;;; Returns: oauth-tokens record+71
(define (oauth-start-callback-server config expected-state current-time+72
. rest)+73
(let ((opts (if (null? rest) #{} (car rest))))+74
(let* ((port (dict-ref opts port: 8085))+75
(verifier (dict-ref opts pkce-verifier: #f))+76
(result-box (list #f))+77
(error-box (list #f))+78
(server-box (list #f)))+79
+80
;; Helper: record an error, stop the server, return an error page+81
(define (fail! msg status)+82
(set-car! error-box msg)+83
(if (car server-box)+84
(http-server-stop (car server-box)))+85
(http-response/html status+86
(make-response-html "Authorization Failed" "#d32f2f" msg)))+87
+88
(let ((handler+89
(lambda (req)+90
(guard (exn+91
(else+92
(log-error "Callback handler error"+93
error: (if (error-object? exn)+94
(error-object-message exn)+95
exn))+96
(fail! "An error occurred during authorization." 500)))+97
(let ((path (http-request-path req))+98
(query (http-request-query req)))+99
(if (not (string=? path "/callback"))+100
(http-response/not-found)+101
(let* ((params (if query+102
(parse-form-urlencoded query)+103
'()))+104
(param-ref (lambda (key)+105
(let ((p (assq key params)))+106
(if p (cdr p) #f)))))+107
;; Check for OAuth error response+108
(let ((err (param-ref 'error)))+109
(if err+110
(let ((desc (or (param-ref 'error_description)+111
err)))+112
(log-error "OAuth authorization denied"+113
error: err description: desc)+114
(fail! desc 400))+115
;; Validate state+116
(let ((state (param-ref 'state)))+117
(if (not (equal? state expected-state))+118
(begin+119
(log-error "OAuth state mismatch"+120
expected: expected-state+121
received: state)+122
(fail! "State parameter mismatch." 400))+123
;; Extract code and exchange+124
(let ((code (param-ref 'code)))+125
(if (not code)+126
(fail! "No authorization code received." 400)+127
(begin+128
(log-info "OAuth code received, exchanging for tokens...")+129
(let ((tokens+130
(oauth-exchange-code+131
config code+132
current-time+133
(if verifier+134
#{ pkce-verifier: verifier }+135
#{}))))+136
(set-car! result-box tokens)+137
(if (car server-box)+138
(http-server-stop (car server-box)))+139
(http-response/html 200+140
success-html))))))))))))))))+141
+142
(let ((server (make-http-server handler+143
port: port+144
host: "127.0.0.1")))+145
(set-car! server-box server)+146
(log-info "OAuth callback server listening"+147
port: port+148
url: (string-append "http://localhost:"+149
(number->string port) "/callback"))+150
(http-server-start server)+151
+152
(cond+153
((car error-box)+154
(error (string-append "OAuth authorization failed: "+155
(car error-box))))+156
((car result-box)+157
(car result-box))+158
(else+159
(error "OAuth callback server stopped without result"))))))))+160
+161
;; ---------------------------------------------------------------+162
;; High-level flow+163
;; ---------------------------------------------------------------+164
+165
;;; Run a complete OAuth authorization code flow.+166
;;;+167
;;; This is the main entry point for CLI-based OAuth. It generates+168
;;; state and PKCE parameters, builds the authorization URL, displays+169
;;; it for the user, starts a local callback server, and returns+170
;;; the tokens after successful authorization.+171
;;;+172
;;; config: oauth-config record+173
;;; current-time: current unix timestamp+174
;;; Optional keyword args via opts dict:+175
;;; port: callback server port (default: 8085)+176
;;; use-pkce: whether to use PKCE (default: #t)+177
;;; open-browser-fn: (lambda (url) ...) to auto-open the URL+178
(define (oauth-run-authorization-flow config current-time . rest)+179
(let ((opts (if (null? rest) #{} (car rest))))+180
(let* ((port (dict-ref opts port: 8085))+181
(use-pkce (dict-ref opts use-pkce: #t))+182
(open-fn (dict-ref opts open-browser-fn: #f))+183
(state (oauth-generate-state))+184
(pkce (if use-pkce (oauth-generate-pkce) #f))+185
(auth-url (oauth-authorization-url config state+186
(if pkce+187
#{ pkce: pkce }+188
#{})))+189
(verifier (if pkce (oauth-pkce-verifier pkce) #f)))+190
+191
(display "Open this URL to authorize:\n\n")+192
(display auth-url)+193
(display "\n\n")+194
(display "Waiting for authorization...\n")+195
+196
(if open-fn+197
(open-fn auth-url))+198
+199
(oauth-start-callback-server config state current-time+200
#{ port: port+201
pkce-verifier: verifier }))))+202
+203
))src/sigil/oauth/store.sgladded
@@ -0,0 +1,115 @@
+1
;;; (sigil oauth store) - Persistent token storage for OAuth tokens.+2
;;;+3
;;; Saves and loads oauth-tokens records as JSON files.+4
;;; Default location: ~/.config/sigil/<service-name>/tokens.json+5
+6
(define-library (sigil oauth store)+7
(import (sigil core)+8
(sigil dict)+9
(sigil string)+10
(sigil struct)+11
(sigil json)+12
(sigil io)+13
(sigil fs)+14
(sigil path)+15
(sigil process)+16
(sigil oauth)+17
(sigil log))+18
+19
(export oauth-token-path+20
oauth-save-tokens+21
oauth-load-tokens+22
oauth-clear-tokens)+23
+24
(begin+25
+26
;; ---------------------------------------------------------------+27
;; Path helpers+28
;; ---------------------------------------------------------------+29
+30
(define (oauth-token-path service-name)+31
(string-append (or (getenv "HOME") ".")+32
"/.config/sigil/" service-name "/tokens.json"))+33
+34
;; ---------------------------------------------------------------+35
;; Token serialization+36
;; ---------------------------------------------------------------+37
+38
(define (tokens->dict tokens)+39
(let ((d #{ access_token: (oauth-tokens-access-token tokens)+40
token_type: (oauth-tokens-token-type tokens) }))+41
(let* ((d (if (oauth-tokens-refresh-token tokens)+42
(dict-set d refresh_token:+43
(oauth-tokens-refresh-token tokens))+44
d))+45
(d (if (oauth-tokens-expires-at tokens)+46
(dict-set d expires_at:+47
(oauth-tokens-expires-at tokens))+48
d))+49
(d (if (not (null? (oauth-tokens-scopes tokens)))+50
(dict-set d scopes:+51
(list->array (oauth-tokens-scopes tokens)))+52
d)))+53
d)))+54
+55
(define (dict->tokens data)+56
(let ((scope-val (dict-ref data scopes: #f)))+57
(oauth-tokens+58
access-token: (dict-ref data access_token:)+59
refresh-token: (dict-ref data refresh_token: #f)+60
expires-at: (dict-ref data expires_at: #f)+61
token-type: (dict-ref data token_type: "Bearer")+62
scopes: (if (and scope-val (array? scope-val))+63
(array->list scope-val)+64
'()))))+65
+66
;; ---------------------------------------------------------------+67
;; File operations+68
;; ---------------------------------------------------------------+69
+70
;;; Save tokens to a JSON file.+71
;;; Optional: custom file path as third argument.+72
(define (oauth-save-tokens service-name tokens . rest)+73
(let ((path (if (null? rest)+74
(oauth-token-path service-name)+75
(car rest))))+76
(ensure-directory (path-dirname path))+77
(write-file-string path (json-encode (tokens->dict tokens)))+78
(log-info "OAuth tokens saved" path: path)+79
path))+80
+81
;;; Load tokens from a JSON file.+82
;;; Returns an oauth-tokens record or #f if unavailable.+83
(define (oauth-load-tokens service-name . rest)+84
(let ((path (if (null? rest)+85
(oauth-token-path service-name)+86
(car rest))))+87
(guard (exn+88
(else+89
(log-debug "Could not load tokens"+90
path: path+91
error: (if (error-object? exn)+92
(error-object-message exn)+93
(let ((p (open-output-string)))+94
(write exn p)+95
(get-output-string p))))+96
#f))+97
(let* ((json-str (call-with-input-file path+98
(lambda (port)+99
(read-string 65536 port))))+100
(data (json-decode json-str)))+101
(log-debug "OAuth tokens loaded" path: path)+102
(dict->tokens data)))))+103
+104
;;; Delete stored tokens for a service.+105
;;; Returns #t if deleted, #f if file didn't exist.+106
(define (oauth-clear-tokens service-name . rest)+107
(let ((path (if (null? rest)+108
(oauth-token-path service-name)+109
(car rest))))+110
(guard (exn (else #f))+111
(delete-file path)+112
(log-info "OAuth tokens cleared" path: path)+113
#t)))+114
+115
))tests/oauth-test.sgladded
@@ -0,0 +1,285 @@
+1
;;; Tests for (sigil oauth)+2
+3
(import (sigil core)+4
(only (scheme base) string->utf8)+5
(sigil test)+6
(sigil dict)+7
(sigil string)+8
(sigil struct)+9
(sigil json)+10
(sigil crypto)+11
(sigil oauth))+12
+13
;; ---------------------------------------------------------------+14
;; Test helpers+15
;; ---------------------------------------------------------------+16
+17
;;; Check if haystack contains needle as a substring.+18
(define (str-contains? haystack needle)+19
(let ((hlen (string-length haystack))+20
(nlen (string-length needle)))+21
(if (> nlen hlen)+22
#f+23
(let loop ((i 0))+24
(cond+25
((> (+ i nlen) hlen) #f)+26
((string=? (substring haystack i (+ i nlen)) needle) #t)+27
(else (loop (+ i 1))))))))+28
+29
;; ---------------------------------------------------------------+30
;; base64url encoding+31
;; ---------------------------------------------------------------+32
+33
(test-group "base64url-encode"+34
+35
(test "encodes empty bytevector"+36
(assert-equal "" (base64url-encode #u8())))+37
+38
(test "strips padding"+39
;; base64("f") = "Zg==" -> base64url should be "Zg"+40
(let ((result (base64url-encode (string->utf8 "f"))))+41
(assert-equal #f (str-contains? result "="))))+42
+43
(test "replaces + and / with URL-safe chars"+44
;; 0xFB 0xFF -> standard base64 = "+/8=" -> base64url = "-_8"+45
(let ((result (base64url-encode #u8(251 255))))+46
(assert-equal #f (str-contains? result "+"))+47
(assert-equal #f (str-contains? result "/"))+48
(assert-equal "-_8" result))))+49
+50
;; ---------------------------------------------------------------+51
;; State generation+52
;; ---------------------------------------------------------------+53
+54
(test-group "oauth-generate-state"+55
+56
(test "returns a non-empty string"+57
(let ((state (oauth-generate-state)))+58
(assert-true (string? state))+59
(assert-true (> (string-length state) 0))))+60
+61
(test "returns different values each time"+62
(let ((s1 (oauth-generate-state))+63
(s2 (oauth-generate-state)))+64
(assert-true (not (string=? s1 s2)))))+65
+66
(test "uses only URL-safe characters"+67
(let ((state (oauth-generate-state)))+68
(assert-equal #f (str-contains? state "+"))+69
(assert-equal #f (str-contains? state "/"))+70
(assert-equal #f (str-contains? state "=")))))+71
+72
;; ---------------------------------------------------------------+73
;; PKCE generation+74
;; ---------------------------------------------------------------+75
+76
(test-group "oauth-generate-pkce"+77
+78
(test "returns an oauth-pkce record"+79
(let ((pkce (oauth-generate-pkce)))+80
(assert-true (oauth-pkce? pkce))))+81
+82
(test "verifier is non-empty URL-safe string"+83
(let ((pkce (oauth-generate-pkce)))+84
(let ((v (oauth-pkce-verifier pkce)))+85
(assert-true (string? v))+86
(assert-true (> (string-length v) 0))+87
(assert-equal #f (str-contains? v "+"))+88
(assert-equal #f (str-contains? v "/"))+89
(assert-equal #f (str-contains? v "=")))))+90
+91
(test "challenge is SHA256 of verifier in base64url"+92
(let* ((pkce (oauth-generate-pkce))+93
(verifier (oauth-pkce-verifier pkce))+94
(expected (base64url-encode (sha256-bytes (string->utf8 verifier)))))+95
(assert-equal expected (oauth-pkce-challenge pkce))))+96
+97
(test "method is S256"+98
(let ((pkce (oauth-generate-pkce)))+99
(assert-equal "S256" (oauth-pkce-method pkce))))+100
+101
(test "different calls produce different verifiers"+102
(let ((p1 (oauth-generate-pkce))+103
(p2 (oauth-generate-pkce)))+104
(assert-true (not (string=? (oauth-pkce-verifier p1)+105
(oauth-pkce-verifier p2)))))))+106
+107
;; ---------------------------------------------------------------+108
;; Authorization URL+109
;; ---------------------------------------------------------------+110
+111
(define test-config+112
(oauth-config+113
client-id: "test-client-id"+114
client-secret: "test-secret"+115
auth-url: "https://example.com/auth"+116
token-url: "https://example.com/token"+117
scopes: '("read" "write")+118
redirect-uri: "http://localhost:8085/callback"))+119
+120
(test-group "oauth-authorization-url"+121
+122
(test "includes required parameters"+123
(let ((url (oauth-authorization-url test-config "test-state")))+124
(assert-true (str-contains? url "response_type=code"))+125
(assert-true (str-contains? url "client_id=test-client-id"))+126
(assert-true (str-contains? url "state=test-state"))+127
(assert-true (str-contains? url "redirect_uri="))+128
(assert-true (str-contains? url "scope=read+write"))))+129
+130
(test "starts with auth-url"+131
(let ((url (oauth-authorization-url test-config "s")))+132
(assert-true (string-starts-with? url "https://example.com/auth?"))))+133
+134
(test "includes PKCE parameters when provided"+135
(let* ((pkce (oauth-generate-pkce))+136
(url (oauth-authorization-url test-config "s"+137
#{ pkce: pkce })))+138
(assert-true (str-contains? url "code_challenge="))+139
(assert-true (str-contains? url "code_challenge_method=S256"))))+140
+141
(test "omits PKCE when not provided"+142
(let ((url (oauth-authorization-url test-config "s")))+143
(assert-equal #f (str-contains? url "code_challenge"))))+144
+145
(test "includes extra params from config"+146
(let* ((config (oauth-config+147
client-id: "id"+148
auth-url: "https://ex.com/auth"+149
token-url: "https://ex.com/token"+150
extra-params: #{ access_type: "offline" }))+151
(url (oauth-authorization-url config "s")))+152
(assert-true (str-contains? url "access_type=offline"))))+153
+154
(test "includes extra params from opts"+155
(let ((url (oauth-authorization-url test-config "s"+156
#{ extra-params: #{ prompt: "consent" } })))+157
(assert-true (str-contains? url "prompt=consent")))))+158
+159
;; ---------------------------------------------------------------+160
;; Token response parsing+161
;; ---------------------------------------------------------------+162
+163
(test-group "parse-token-response"+164
+165
(test "parses complete response"+166
(let* ((data #{ access_token: "at123"+167
refresh_token: "rt456"+168
expires_in: 3600+169
token_type: "Bearer"+170
scope: "read write" })+171
(tokens (parse-token-response data 1000)))+172
(assert-true (oauth-tokens? tokens))+173
(assert-equal "at123" (oauth-tokens-access-token tokens))+174
(assert-equal "rt456" (oauth-tokens-refresh-token tokens))+175
(assert-equal 4600 (oauth-tokens-expires-at tokens))+176
(assert-equal "Bearer" (oauth-tokens-token-type tokens))+177
(assert-equal '("read" "write") (oauth-tokens-scopes tokens))))+178
+179
(test "handles missing optional fields"+180
(let* ((data #{ access_token: "at123" })+181
(tokens (parse-token-response data 1000)))+182
(assert-equal "at123" (oauth-tokens-access-token tokens))+183
(assert-equal #f (oauth-tokens-refresh-token tokens))+184
(assert-equal #f (oauth-tokens-expires-at tokens))+185
(assert-equal '() (oauth-tokens-scopes tokens))))+186
+187
(test "computes expires-at from current time + expires_in"+188
(let* ((data #{ access_token: "at" expires_in: 7200 })+189
(tokens (parse-token-response data 5000)))+190
(assert-equal 12200 (oauth-tokens-expires-at tokens)))))+191
+192
;; ---------------------------------------------------------------+193
;; Token expiry checking+194
;; ---------------------------------------------------------------+195
+196
(test-group "oauth-token-expired?"+197
+198
(test "not expired when well before expiry"+199
(let ((tokens (oauth-tokens+200
access-token: "t"+201
expires-at: 2000)))+202
(assert-equal #f (oauth-token-expired? tokens 1000))))+203
+204
(test "expired when past expiry"+205
(let ((tokens (oauth-tokens+206
access-token: "t"+207
expires-at: 1000)))+208
(assert-true (oauth-token-expired? tokens 1500))))+209
+210
(test "expired within grace period (default 60s)"+211
(let ((tokens (oauth-tokens+212
access-token: "t"+213
expires-at: 1050)))+214
(assert-true (oauth-token-expired? tokens 1000))))+215
+216
(test "not expired just outside grace period"+217
(let ((tokens (oauth-tokens+218
access-token: "t"+219
expires-at: 1100)))+220
(assert-equal #f (oauth-token-expired? tokens 1000))))+221
+222
(test "custom grace period"+223
(let ((tokens (oauth-tokens+224
access-token: "t"+225
expires-at: 1200)))+226
;; 300s grace: 1200 - 300 = 900, current = 950, so expired+227
(assert-true (oauth-token-expired? tokens 950 300))+228
;; current = 800, so not expired+229
(assert-equal #f (oauth-token-expired? tokens 800 300))))+230
+231
(test "no expiry info returns not-expired"+232
(let ((tokens (oauth-tokens+233
access-token: "t"+234
expires-at: #f)))+235
(assert-equal #f (oauth-token-expired? tokens 99999)))))+236
+237
;; ---------------------------------------------------------------+238
;; Provider presets+239
;; ---------------------------------------------------------------+240
+241
(test-group "oauth-google-config"+242
+243
(test "sets correct Google auth URLs"+244
(let ((config (oauth-google-config "cid" "csec"+245
'("https://www.googleapis.com/auth/youtube"))))+246
(assert-equal "https://accounts.google.com/o/oauth2/v2/auth"+247
(oauth-config-auth-url config))+248
(assert-equal "https://oauth2.googleapis.com/token"+249
(oauth-config-token-url config))))+250
+251
(test "includes access_type=offline in extra params"+252
(let ((config (oauth-google-config "cid" "csec" '())))+253
(assert-equal "offline"+254
(dict-ref (oauth-config-extra-params config)+255
access_type:))))+256
+257
(test "preserves client credentials and scopes"+258
(let ((config (oauth-google-config "my-id" "my-secret"+259
'("scope1" "scope2"))))+260
(assert-equal "my-id" (oauth-config-client-id config))+261
(assert-equal "my-secret" (oauth-config-client-secret config))+262
(assert-equal '("scope1" "scope2") (oauth-config-scopes config)))))+263
+264
(test-group "oauth-twitch-config"+265
+266
(test "sets correct Twitch auth URLs"+267
(let ((config (oauth-twitch-config "cid" "csec"+268
'("channel:manage:broadcast"))))+269
(assert-equal "https://id.twitch.tv/oauth2/authorize"+270
(oauth-config-auth-url config))+271
(assert-equal "https://id.twitch.tv/oauth2/token"+272
(oauth-config-token-url config))))+273
+274
(test "preserves scopes"+275
(let ((config (oauth-twitch-config "c" "s"+276
'("channel:manage:broadcast"+277
"user:read:chat"))))+278
(assert-equal '("channel:manage:broadcast" "user:read:chat")+279
(oauth-config-scopes config))))+280
+281
(test "accepts custom redirect URI"+282
(let ((config (oauth-twitch-config "c" "s" '()+283
#{ redirect-uri: "http://localhost:9999/cb" })))+284
(assert-equal "http://localhost:9999/cb"+285
(oauth-config-redirect-uri config)))))tests/store-test.sgladded
@@ -0,0 +1,110 @@
+1
;;; Tests for (sigil oauth store)+2
+3
(import (sigil core)+4
(sigil test)+5
(sigil dict)+6
(sigil string)+7
(sigil struct)+8
(sigil json)+9
(sigil io)+10
(sigil fs)+11
(sigil oauth)+12
(sigil oauth store))+13
+14
;; Test helper+15
(define (str-contains? haystack needle)+16
(let ((hlen (string-length haystack))+17
(nlen (string-length needle)))+18
(if (> nlen hlen)+19
#f+20
(let loop ((i 0))+21
(cond+22
((> (+ i nlen) hlen) #f)+23
((string=? (substring haystack i (+ i nlen)) needle) #t)+24
(else (loop (+ i 1))))))))+25
+26
;; Use a temporary directory for test files+27
(define test-dir "/tmp/sigil-oauth-test")+28
(define test-file (string-append test-dir "/tokens.json"))+29
+30
;; Clean up before tests+31
(define (cleanup-test-files)+32
(if (file-exists? test-file)+33
(delete-file test-file))+34
(if (file-exists? test-dir)+35
(delete-directory test-dir)))+36
+37
(cleanup-test-files)+38
+39
;; ---------------------------------------------------------------+40
;; Token path+41
;; ---------------------------------------------------------------+42
+43
(test-group "oauth-token-path"+44
+45
(test "builds path under ~/.config/sigil/"+46
(let ((path (oauth-token-path "youtube")))+47
(assert-true (str-contains? path ".config/sigil/youtube/tokens.json")))))+48
+49
;; ---------------------------------------------------------------+50
;; Save and load round-trip+51
;; ---------------------------------------------------------------+52
+53
(test-group "oauth-save-tokens and oauth-load-tokens"+54
+55
(test "save and load full token set"+56
(let ((tokens (oauth-tokens+57
access-token: "access-123"+58
refresh-token: "refresh-456"+59
expires-at: 1711382400+60
token-type: "Bearer"+61
scopes: '("read" "write"))))+62
(oauth-save-tokens "test" tokens test-file)+63
(let ((loaded (oauth-load-tokens "test" test-file)))+64
(assert-true (oauth-tokens? loaded))+65
(assert-equal "access-123" (oauth-tokens-access-token loaded))+66
(assert-equal "refresh-456" (oauth-tokens-refresh-token loaded))+67
(assert-equal 1711382400 (oauth-tokens-expires-at loaded))+68
(assert-equal "Bearer" (oauth-tokens-token-type loaded))+69
(assert-equal '("read" "write") (oauth-tokens-scopes loaded)))))+70
+71
(test "save and load minimal token set"+72
(let ((tokens (oauth-tokens access-token: "minimal-token")))+73
(oauth-save-tokens "test" tokens test-file)+74
(let ((loaded (oauth-load-tokens "test" test-file)))+75
(assert-true (oauth-tokens? loaded))+76
(assert-equal "minimal-token" (oauth-tokens-access-token loaded))+77
(assert-equal #f (oauth-tokens-refresh-token loaded))+78
(assert-equal #f (oauth-tokens-expires-at loaded)))))+79
+80
(test "load returns #f for missing file"+81
(let ((result (oauth-load-tokens "test"+82
"/tmp/sigil-oauth-test-nonexistent/tokens.json")))+83
(assert-equal #f result)))+84
+85
(test "load returns #f for invalid JSON"+86
(write-file-string test-file "not valid json {{")+87
(let ((result (oauth-load-tokens "test" test-file)))+88
(assert-equal #f result))))+89
+90
;; ---------------------------------------------------------------+91
;; Clear tokens+92
;; ---------------------------------------------------------------+93
+94
(test-group "oauth-clear-tokens"+95
+96
(test "deletes existing token file"+97
(let ((tokens (oauth-tokens access-token: "to-delete")))+98
(oauth-save-tokens "test" tokens test-file)+99
(assert-true (file-exists? test-file))+100
(let ((result (oauth-clear-tokens "test" test-file)))+101
(assert-equal #t result)+102
(assert-equal #f (file-exists? test-file)))))+103
+104
(test "returns #f for non-existent file"+105
(let ((result (oauth-clear-tokens "test"+106
"/tmp/sigil-oauth-nonexistent-clear.json")))+107
(assert-equal #f result))))+108
+109
;; Final cleanup+110
(cleanup-test-files)