All Files in assets/oauth2/
Not logged in

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:

  1. Authorize — the user's browser is sent to the provider's -auth_url. They approve, and the provider redirects back to -redirect_uri with a short-lived code.
  2. Catch the redirect — the library runs a one-shot local web server, reads the code (and any extras, like Intuit's realmId) out of the redirect query string, and shows a "you can close this tab" page.
  3. Exchange — the code is POSTed to -token_url and traded for an access_token (+ usually a refresh_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 handleaccess_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 handletokens

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 jwtdict {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 browseropen on macOS, xdg-open on Linux/BSD, and rundll32 url.dll,FileProtocolHandler on Windows (chosen because it passes the URL as a single argument, so the & in OAuth URLs isn't eaten by cmd.exe).
  • Home directory & ~ paths — resolved from HOME, then USERPROFILE, then HOMEDRIVE+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:

  1. the OAUTH2_CAFILE, SSL_CERT_FILE, or CURL_CA_BUNDLE environment variable, if it points at an existing file;
  2. 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/ (mode 0600) 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

  1. Find the provider's authorize URL, token URL, and how it wants client credentials presented (body vs basic).
  2. Register a redirect URI you control. If the provider forbids localhost, stand up a tiny bounce page and set -listen_port/-listen_path.
  3. Map any spec deviations into -auth_extra / -token_exchange_extra / -token_refresh_extra.
  4. Wrap it in a make_client factory proc like the ones in the examples/*_login.tcl programs.

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/*.json with mode 0600 and are excluded from version control by .gitignore.
  • The state parameter is generated per-login and verified on the redirect as a CSRF guard.

Documentation and tests

  • Reference manualoauth2.man is the package's reference in Tcllib's doctools format. Render it with dtplite:

  dtplite -o oauth2.n   nroff oauth2.man     # man page
  dtplite -o oauth2.html html  oauth2.man    # HTML

  • Test suiteoauth2.test is a tcltest suite 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 the examples/).

  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.