Files in directory assets/oauth2 in any check-in
- examples
- license.terms
- oauth2.man
- oauth2.tcl
- oauth2.test
- pkgIndex.tcl
- README.md
oauth2 — a small OAuth 2.0 client for Tcl
A self-contained implementation of the OAuth 2.0 Authorization Code flow in
pure Tcl. It opens the browser, catches the redirect on a local socket,
exchanges the code for tokens, persists them to a 0600 JSON file, refreshes
expired access tokens transparently, and gives you a one-liner for making
authenticated API calls.
Pure Tcl, no third-party libraries. It uses only http (bundled with the
core Tcl distribution) and tls (the standard Tcl TLS extension). JSON is
parsed by a small decoder included in the file, and base64 by core Tcl's
binary encode base64 — so nothing from Tcllib is required. tls is the one
unavoidable extension: HTTPS cannot be done in pure Tcl, as there is no
pure-Tcl TLS handshake.
It is provider-agnostic. The same code drives Basecamp, QuickBooks Online,
and Twitter/X even though they each bend the spec in different ways — every
quirk is expressed as configuration, not code. Optional PKCE (RFC 7636) is
a single -pkce S256 option, with the SHA-256 it needs implemented in-package
so the dependency footprint stays at just http + tls. Three worked examples
are included, each runnable as plain Tcl or behind a small Tk UI.
Loading the library
Two ways, depending on your setup:
# A) via the package system — put the dir on auto_path, then require it
lappend auto_path /path/to/tcl-oauth2
package require oauth2
# B) source it directly — handy when deploying alongside other code in a
# shared lib dir, or when you don't want to touch auto_path/pkgIndex
source /path/to/oauth2.tcl
Both leave oauth2::* defined. oauth2.tcl ends with package provide oauth2,
so a later package require oauth2 is satisfied without re-loading. (B) is
exactly how a thin wrapper can self-locate the library next to itself:
source [file join [file dirname [info script]] oauth2.tcl].
Quick start
lappend auto_path /path/to/tcl-oauth2
package require oauth2
set client [oauth2::new \
-name myapp \
-auth_url https://provider.example/authorize \
-token_url https://provider.example/token \
-client_id abc123 \
-client_secret s3cr3t \
-redirect_uri http://localhost:9876/callback \
-scope "read write" \
-auth_extra {response_type code} \
-token_exchange_extra {grant_type authorization_code} \
-token_refresh_extra {grant_type refresh_token} \
-token_file ~/.config/myapp/tokens.json]
oauth2::login $client ;# first run: opens browser, saves tokens
set json [oauth2::request $client GET https://api.example/v1/things]
After the first login, tokens live in -token_file and are refreshed
automatically — subsequent runs skip straight to oauth2::request.
Concepts
The library implements the classic three-legged dance:
- Authorize — the user's browser is sent to the provider's
-auth_url. They approve, and the provider redirects back to-redirect_uriwith a short-livedcode. - Catch the redirect — the library runs a one-shot local web server,
reads the
code(and any extras, like Intuit'srealmId) out of the redirect query string, and shows a "you can close this tab" page. - Exchange — the
codeis POSTed to-token_urland traded for anaccess_token(+ usually arefresh_token). Tokens are saved to disk.
Thereafter, oauth2::token hands you a valid access token, refreshing it
behind your back when it is within 60 seconds of expiry, and oauth2::request
attaches it as a Bearer header.
API reference
oauth2::new ?-option value ...? → handle
Creates a client and returns an opaque handle used by every other command. If
-token_file already exists, its tokens are loaded immediately.
Required
| option | meaning |
|---|---|
-auth_url |
authorization endpoint (where the browser goes) |
-token_url |
token endpoint (code & refresh exchange) |
-client_id |
OAuth client id |
-client_secret |
OAuth client secret |
-redirect_uri |
redirect URI registered with the provider |
Common
| option | default | meaning |
|---|---|---|
-scope |
"" |
space-separated scopes; omitted from the URL when empty |
-token_file |
"" |
where to persist tokens (JSON, mode 0600) |
-token_auth |
body |
how client creds reach the token endpoint: body (in the form body) or basic (HTTP Basic header — Intuit/QBO) |
-pkce |
"" |
PKCE (RFC 7636): "" off, plain (challenge = verifier), or S256 (challenge = base64url-sha256 of verifier). Required by Twitter/X; recommended elsewhere |
-default_headers |
{} |
{k v ...} headers added to every oauth2::request |
-validate_url |
"" |
a lightweight GET endpoint used by oauth2::validate to live-test the token; without it validate does an offline expiry check |
-introspect_url |
"" |
token-introspection (RFC 7662) endpoint used by oauth2::introspect |
-name |
oauth2 |
friendly label used in messages |
Provider-quirk knobs — each a {k v k v ...} dict merged into the relevant
request. This is how one code path serves wildly different providers:
| option | textbook (QBO, Twitter/X) | Basecamp |
|---|---|---|
-auth_extra |
{response_type code} |
{type web_server} |
-token_exchange_extra |
{grant_type authorization_code} |
{type web_server} |
-token_refresh_extra |
{grant_type refresh_token} |
{type refresh} |
Twitter/X is textbook OAuth2 plus mandatory PKCE — add -pkce S256 and
nothing else changes.
Local-callback knobs — normally derived from -redirect_uri. Override them
when the registered redirect is a public bounce page that 302s back to
localhost (Intuit bans localhost redirect URIs in production):
| option | meaning |
|---|---|
-listen_port |
TCP port the local callback server binds to |
-listen_path |
URL path the provider redirects back to (e.g. /callback) |
oauth2::login handle ?-open 0|1?
Runs the full interactive flow: builds the authorize URL, opens it in the
browser (unless -open 0), waits for the redirect, verifies the state
parameter (CSRF guard), exchanges the code, and saves tokens. Any non-OAuth
redirect parameters (e.g. realmId) are stored under the token's extra key.
Returns the token dict.
oauth2::token handle → access_token
Returns a currently-valid access token, loading from disk and/or refreshing as
needed. Throws if there is nothing to work with (no tokens and no way to get
them — i.e. you need to login).
oauth2::request handle METHOD url ?-query d? ?-headers l? ?-body s? ?-type t? → body
Makes an authenticated call over TLS. Adds the Bearer token and
-default_headers, appends -query as a URL query string, and on an HTTP
401 it refreshes the token once and retries (so an expired key heals itself
transparently). Returns the response body on 2xx; throws (with the body)
otherwise.
-query—{k v ...}dict, URL-encoded onto the query string-headers—{k v ...}extra request headers-body/-type— request body and its content-type (for POST/PUT)
oauth2::get handle url ?-query d? ?-headers l? → body
oauth2::post handle url ?-body s? ?-type t? ?-query d? ?-headers l? → body
Convenience wrappers over request for authenticated HTTPS GET and
POST. Same behaviour: Bearer auth, -default_headers, TLS via the
registered https transport, and auto-refresh-and-retry on a 401.
set body [oauth2::get $c https://api.example/v1/items -query {limit 50}]
set body [oauth2::post $c https://api.example/v1/items -body $json -type application/json]
oauth2::validate handle ?-url URL? ?-autorefresh 0|1? → 0|1
Tests whether the access token is currently valid and working. With a test
URL available (the -url argument, else the client's -validate_url), it makes
a live GET with the current token and returns 1 only if the server answers
2xx. By default (-autorefresh 1) a failed test triggers one refresh-and-retest,
so a 1 means the stored token now works; pass -autorefresh 0 to test only the
current token. With no test URL it falls back to an offline expiry check. Always
returns a boolean and never throws.
if {![oauth2::validate $c]} { error "cannot get a working token" }
oauth2::refresh handle → tokens
Forces a refresh-token grant now and saves. Rarely needed directly — token,
request, get, post, and validate call it for you.
oauth2::client_credentials handle ?-scope s? → tokens
The Client Credentials grant (RFC 6749 §4.4): a two-legged, machine-to-machine
flow with no user/browser. POSTs grant_type=client_credentials to the token
endpoint (client auth per -token_auth) and stores the access token. The best
non-interactive test that a client id/secret + token endpoint work.
oauth2::introspect handle token ?-url URL? → dict
Token Introspection (RFC 7662): asks the authorization server whether token
is active and returns the parsed metadata (its active member is true/false).
Uses -url else the client's -introspect_url, with client auth per -token_auth.
oauth2::jwt_decode jwt → dict {header … payload …}
Decodes (does not verify) a JSON Web Token: base64url-decodes and JSON-parses
the header and payload. Handy for inspecting an access token's iss/aud/scope/exp.
set t [dict get [oauth2::client_credentials $c] access_token]
puts [oauth2::json_get [dict get [oauth2::jwt_decode $t] payload] scope]
Other commands
| command | purpose |
|---|---|
oauth2::authorize_url handle |
the URL to visit (for headless/manual flows) |
oauth2::exchange_code handle code |
exchange a code you obtained yourself |
oauth2::tokens handle |
the current token dict (incl. extra) |
oauth2::set_tokens handle dict |
seed tokens in memory (e.g. from another store) |
oauth2::save handle / oauth2::load handle |
persist / reload the token file |
oauth2::logout handle |
forget tokens and delete the token file |
oauth2::config handle key |
read back a config value |
oauth2::json_parse text |
decode a JSON string to a Tcl dict/list (the built-in parser, handy for response bodies) |
oauth2::json_get value path |
reach into decoded JSON by dotted path with array indices, e.g. QueryResponse.Item[0].Id; returns "" if missing (mirrors Chilkat's CkJsonObject_stringOf) |
Token file format
{
"access_token": "…",
"refresh_token": "…",
"token_type": "bearer",
"obtained_at": 1780665175,
"expires_at": 1781874715,
"extra": {
"realmId": "123456789"
}
}
Written with mode 0600. expires_at is computed from the provider's
expires_in; extra holds non-OAuth redirect params worth keeping.
Cross-platform notes
The library runs unchanged on Unix, macOS, and Windows. Platform differences are handled internally:
- Opening the browser —
openon macOS,xdg-openon Linux/BSD, andrundll32 url.dll,FileProtocolHandleron Windows (chosen because it passes the URL as a single argument, so the&in OAuth URLs isn't eaten bycmd.exe). - Home directory &
~paths — resolved fromHOME, thenUSERPROFILE, thenHOMEDRIVE+HOMEPATH, so-token_file ~/.config/...works everywhere. - Null device, path separators,
file mkdir— handled via Tcl's portable primitives.
Only tclsh plus the tls extension is required; both are available on all
three platforms (ActiveTcl, Magicsplat, Homebrew, system packages, …).
TLS notes
HTTPS is wired up at load time via http::register, and server certificates
are verified against a CA bundle. The bundle is located in this order:
- the
OAUTH2_CAFILE,SSL_CERT_FILE, orCURL_CA_BUNDLEenvironment variable, if it points at an existing file; - the usual Unix/macOS locations (
/etc/ssl/cert.pem, Homebrew/MacPorts OpenSSL,ca-bundle.crt, …).
If none is found, the connection falls back to unverified and a warning is
printed once. Windows ships no PEM bundle and tls does not read the Windows
certificate store, so on Windows you should download a bundle (e.g.
cacert.pem from curl.se) and point OAUTH2_CAFILE at it:
set OAUTH2_CAFILE=C:\path\to\cacert.pem &:: cmd
$env:OAUTH2_CAFILE = "C:\path\to\cacert.pem" # PowerShell
Worked examples
Three self-standing programs live in examples/. Each is a single
file that depends only on the oauth2 library, configures one provider inline,
runs the login, and makes one demonstrative API call. Every one runs two
ways — plain Tcl, or behind a small Tk UI with -gui 1:
tclsh examples/twitter_login.tcl # plain Tcl
tclsh examples/twitter_login.tcl -gui 1 # simple Tk window
Every setting can be supplied either on the command line or in the
environment — the command line wins. Run any example with -h for its full
option list. For example these are equivalent:
export TWITTER_CLIENT_ID=abc TWITTER_CLIENT_SECRET=xyz
tclsh examples/twitter_login.tcl
# …same as…
tclsh examples/twitter_login.tcl -client_id abc -client_secret xyz
Credentials are never stored in this repo. Register your own OAuth apps with each provider and supply the client id/secret via environment variables (below). Tokens obtained at runtime are cached under
~/.config/tcl-oauth2/(mode0600) and are likewise never committed.
Twitter / X — show who you are (twitter_login.tcl)
export TWITTER_CLIENT_ID=...
export TWITTER_CLIENT_SECRET=... # omit for a public (PKCE-only) client
tclsh examples/twitter_login.tcl # or: -gui 1
Twitter/X mandates the Authorization-Code flow with PKCE. The example turns
it on with -pkce S256; the library mints a code verifier, sends its SHA-256
challenge on the authorize URL, and replays the verifier on the token exchange.
It then calls GET /2/users/me. A confidential client (with a secret)
authenticates with HTTP Basic; a public client (no secret) relies on PKCE alone
— the example picks the right mode automatically.
Basecamp — identity, accounts, projects (basecamp_login.tcl)
export BASECAMP_CLIENT_ID=... # from your 37signals OAuth app
export BASECAMP_CLIENT_SECRET=...
tclsh examples/basecamp_login.tcl # or: -gui 1
Basecamp (37signals Launchpad) uses type=web_server instead of
response_type=code/grant_type=…. The first run pops a browser login, caches
the tokens, then shows your identity and accounts (GET /authorization.json —
no account id needed) and lists the projects on your first Basecamp 3 account.
QuickBooks Online — the connected company (qbo_login.tcl)
export QBO_CLIENT_ID=... # from your Intuit app
export QBO_CLIENT_SECRET=...
export QBO_REDIRECT_URI=https://your.site/oauth_bounce # 302s to localhost:8181/callback
tclsh examples/qbo_login.tcl # production (or: -gui 1)
tclsh examples/qbo_login.tcl sandbox # sandbox
Intuit is close to textbook OAuth2 but needs HTTP Basic auth on the token
endpoint (-token_auth basic) and, in production, bans localhost redirect
URIs — so you register a public page that 302-redirects to
http://localhost:8181/callback, point QBO_REDIRECT_URI at it, and the local
callback server still listens on 8181. A QBO authorization grants access to one
company (realm); its realmId arrives on the redirect and is used to fetch
the company's profile from the CompanyInfo endpoint.
Testing your OAuth2 setup
examples/testing/ has five runnable programs, each
demonstrating a standard way to test an OAuth2 configuration (and serving as
a smoke test for this library). They run as-is against the public
Duende IdentityServer demo — no registration —
then point at your own provider by editing the few lines at the top.
| Program | Technique |
|---|---|
01_oidc_discovery.tcl |
fetch /.well-known/openid-configuration, confirm endpoints + TLS |
02_client_credentials.tcl |
Client Credentials grant → call a protected API (RFC 6749 §4.4) |
03_introspect.tcl |
Token Introspection — is this token active? (RFC 7662) |
04_validate_token.tcl |
live "is my token valid & working?" check, incl. a tampered token |
05_decode_jwt.tcl |
decode & inspect a JWT's claims (iss/aud/scope/exp) |
cd examples/testing && tclsh 02_client_credentials.tcl
See examples/testing/README.md for what each one
checks and the common failure modes it surfaces.
Adding another provider
- Find the provider's authorize URL, token URL, and how it wants client
credentials presented (
bodyvsbasic). - Register a redirect URI you control. If the provider forbids
localhost, stand up a tiny bounce page and set-listen_port/-listen_path. - Map any spec deviations into
-auth_extra/-token_exchange_extra/-token_refresh_extra. - Wrap it in a
make_clientfactory proc like the ones in theexamples/*_login.tclprograms.
That's usually the whole job — no changes to oauth2.tcl.
Security
- Client secrets are read from the environment, never hard-coded in the repo.
- Tokens are written to
~/.config/tcl-oauth2/*.jsonwith mode0600and are excluded from version control by.gitignore. - The
stateparameter is generated per-login and verified on the redirect as a CSRF guard.
Documentation and tests
- Reference manual —
oauth2.manis the package's reference in Tcllib's doctools format. Render it withdtplite:
dtplite -o oauth2.n nroff oauth2.man # man page
dtplite -o oauth2.html html oauth2.man # HTML
- Test suite —
oauth2.testis atcltestsuite covering everything checkable offline: the SHA-256 known-answer vectors, the RFC 7636 PKCE vector, the JSON parser and path accessor, JWT decoding, authorize-URL construction, option validation, and token persistence (the interactive login and HTTP paths are exercised by theexamples/).
tclsh oauth2.test
Packaging for Tcllib
This package is structured to drop into Tcllib
as the module modules/oauth2/:
| file | role |
|---|---|
oauth2.tcl |
the implementation (package provide oauth2 1.0) |
pkgIndex.tcl |
package ifneeded oauth2 1.0 [list source [file join $dir oauth2.tcl]] |
oauth2.man |
doctools reference manual |
oauth2.test |
tcltest suite |
The only dependencies are http and tls, both already used by other Tcllib
modules. On submission, the oauth2.test preamble can be swapped for Tcllib's
standard testutilities harness (testing { useLocal oauth2.tcl oauth2 }).
License
Tcl/Tk license (BSD-style). Copyright © 2026 John Buckman. See license.terms for the full text — the same permissive license used by Tcl/Tk and Tcllib.