ApiKeyDemo

Punk::Plugin::APIKey end to end: keys minted in a browser, spent from a terminal, scoped, revocable, rate limited per key, and answerable to their owner's current standing.

Generated with punk new ApiKeyDemo --sqitch sqlite and then wired up, so the layout is the one punk new gives everybody.

Running it

punk sqitch deploy      # two projects: the plugin's, then this one's
plackup app.psgi        # or: hyperman app.psgi

Then open http://localhost:5000/, sign in with any address containing an @ (there is no password - see below), and mint a key.

punk sqitch deploy is the first thing worth watching:

# project punk_apikey (.../Punk/Plugin/APIKey/sqitch)
  + api_keys .. ok
# project apikeydemo
  + users .. ok
  + notes .. ok

The api_keys table is not in this application's schema. The plugin ships its own Sqitch project and registers it, and it deploys before this project because a plugin's schema cannot depend on the application's.

Spending a key

Mint it with read write admin to run all four of these - each block needs the scope above it, and a key holding only read gets 403 Forbidden on the write. That is the guard working.

KEY=sk_live_...

# read
curl -H "Authorization: Bearer $KEY" localhost:5000/api/v1/whoami
curl -H "Authorization: Bearer $KEY" localhost:5000/api/v1/notes

# write
curl -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
     -d '{"body":"from CI"}' localhost:5000/api/v1/notes

# admin
curl -H "Authorization: Bearer $KEY" localhost:5000/api/v1/admin/stats

/whoami answers with both sets of scopes, which is the point of it:

{ "owner": 1, "kind": "live", "label": "curl",
  "prefix": "sk_live_-yraveOc",
  "scopes":  ["admin","read","write"],
  "granted": "read write admin" }

granted is what the key's row says. scopes is what the guard actually tested, after the owner's current role narrowed it.

The four things worth clicking

Mint one. The key is printed once. Nothing stored it: the row keeps a SHA-256 digest and a prefix of the kind prefix plus the first eight random characters - enough to recognise a key in a list, not enough to rebuild one. The keys page prints prefixes and never a whole key.

Demote yourself. /api/v1/admin/stats starts answering 403 and everything else keeps working:

after demote:
  stats:  403
  notes:  200
  whoami: {"scopes":["read","write"], "granted":"read write admin", ...}

The key's row did not change. scope_rank maps each scope to the minimum rank on auth's ladder that may exercise it, and a scope the owner's role no longer reaches is dropped from the effective set for that request. A former admin's CI keeps deploying and stops administering, which is what demotion means.

Suspend yourself. Every route answers 403, not 401. The caller has already proved they hold the key, so there is nothing to enumerate and "your account is suspended" is the useful answer rather than a lie about the credential.

Revoke a key. It answers 401 from then on, and the row stays in the list: revoking is a timestamp, not a delete, so the audit of what was revoked when survives.

Refusals

Every reason a credential is not good - missing, malformed, a bad checksum, an unknown kind, an unknown digest, revoked, expired, an owner who is gone - is one 401 with WWW-Authenticate: Bearer and the same body:

no key:  401 WWW-Authenticate: Bearer
typo:    401

A client that could tell "unknown" from "revoked" could enumerate keys. A scope the key lacks is a 403. There is no redirect and no content negotiation: a browser is not what is on the other end of a key.

The last six characters of a key are a CRC32 checksum in base62, so a truncated or mistyped key is refused before the database is touched - and a secret scanner can recognise the format and revoke a leaked key before anyone uses it.

From the command line

punk apikey reads this application's own configuration, so the table, the kinds and the scope vocabulary are the ones it declares:

$ punk apikey list
id  owner  kind  label  prefix            scopes            last used  state
1   1      live  curl   sk_live_-yraveOc  read write admin  36s ago    live

$ punk apikey issue --owner 1 --label deploy --scopes read
$ punk apikey revoke 1

Where to look

| file | what | |---|---| | lib/ApiKeyDemo.pm | the routing table and the whole plugin configuration - read this first | | config/punk.yml | views, static, the SQLite dsn, the models | | lib/ApiKeyDemo/Controller/Web/Keys.pm | minting, listing, revoking, and the standing switches | | lib/ApiKeyDemo/Controller/API/V1.pm | what sits behind the guards | | sqitch/ | this project's users and notes; api_keys comes from the plugin | | t/01-basic.t | the whole story above, asserted |

Two things this demo is not

There is no password. Signing in takes an email and creates the account on first sight, because a password field would be the one part of this nobody needed to read. Punk::Auth has the other half, and $c->login is the same call either way.

The standing switches are self-service. Demoting and suspending yourself from your own account page is not a design anybody should copy - they are there so the effect on a live key is one click away.

Two details in the code that are worth copying, though:

Tests

prove -l t

The suite deploys its own schema into var/test/ first - its own directory, because Sqitch keeps the SQLite registry beside the target database and a test database sharing a directory with the development one would be told everything was already deployed.