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
lib
README.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-log
src/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)