Operations Guide — OAuth Login & Token Setup
Last updated: 2026-04-11
This guide covers the steps to complete OAuth login setup and configure scoped tokens across your projects. All steps below were verified end-to-end on 2026-04-11.
Prerequisites
- Registry deployed at
https://api.packr.blueforge.studio(Fly.io) - GitHub OAuth App configured (client ID:
Ov23ligJE4NpN7NysXzL) - OAuth credentials set on Fly.io
-
OAUTH_REDIRECT_BASE=https://packr.blueforge.studioset on Fly.io - GitHub OAuth App callback URL =
https://packr.blueforge.studio/api/auth/callback/github -
packr-clibinary built atbin/packr-cli - Dashboard deployed at
https://packr.blueforge.studio(Vercel)
Step 1: Deploy Dashboard (Vercel) — ✅ DONE
The device flow verification page (/cli/auth) and the GitHub OAuth callback route (/api/auth/callback/github) are live.
To redeploy after site changes:
cd packages/site
vercel deploy --prod
vercel alias set <new-deployment-url> packr.blueforge.studio
Required Vercel environment variables (Production):
REGISTRY_URL/PACKR_REGISTRY_URL—https://api.packr.blueforge.studioINTERNAL_API_SECRET— must match the same value on Fly.ioSESSION_SECRET— must match Fly.ioGITHUB_OAUTH_CLIENT_ID/GITHUB_OAUTH_CLIENT_SECRET— dashboard web loginNEXT_PUBLIC_SITE_URL—https://packr.blueforge.studioNEXT_PUBLIC_REGISTRY_URL—https://api.packr.blueforge.studio
Step 2: First OAuth Login
# From the packr-registry repo root
./bin/packr-cli login --provider github --registry https://api.packr.blueforge.studio
What happens:
- CLI prints a verification URL and a code like
ABCD-EFGH - Browser opens to
https://packr.blueforge.studio/cli/auth - Enter the code, click Authorize
- GitHub OAuth consent screen appears — authorize
- Browser shows "CLI Authorized. You can close this tab."
- CLI prints:
Logged in as <username> (maintainer) via github
What gets created:
~/.packr/credentials.json— token + metadata~/.npmrc— updated with@blueforge-studio:registry=...and auth token
Verify:
./bin/packr-cli whoami --registry https://api.packr.blueforge.studio
# Expected: "Logged in as <username> (maintainer) via github — expires 2026-05-10"
Step 3: Test Package Install
# In any project directory
pnpm install @blueforge-studio/some-package
# Should resolve from Packr registry using the token in ~/.npmrc
Step 4: Create Scoped CI Tokens — ✅ DONE
Important: packr-cli token create from your laptop uses the admin API, not the local DB. It only works if you're logged in (packr-cli login) and pass --registry or set PACKR_REGISTRY.
# Set once in your shell
export PACKR_REGISTRY=https://api.packr.blueforge.studio
# Read-only token (for install / most CI jobs)
./bin/packr-cli token create ci-read --role ci-readonly
# Publish token (scoped to @blueforge-studio, for release workflows)
./bin/packr-cli token create ci-publish --role ci-publish --scope @blueforge-studio
Each command prints the JWT to stdout (safe to pipe into gh secret set) and a confirmation to stderr.
Setting GitHub Org Secrets — pitfalls to avoid
Use stdin with printf, not --body (avoids line-wrap corruption):
# Unset GITHUB_TOKEN if it's in your shell — otherwise gh ignores keychain auth
unset GITHUB_TOKEN
# Make sure gh has admin:org scope
gh auth refresh -h github.com -s admin:org
# Set the tokens
printf '%s' '<ci-read-token>' | gh secret set PACKR_TOKEN --org blueforge-studio
printf '%s' '<ci-publish-token>' | gh secret set PACKR_TOKEN_PUBLISH --org blueforge-studio
Critical gotcha: repo-level secrets shadow org-level secrets. If a repo already has a PACKR_TOKEN secret (leftover from a previous setup), the org-level one is ignored. Check with:
cd /path/to/repo && gh secret list # shows REPO-level secrets
gh secret list --org blueforge-studio # shows ORG-level secrets
# If there's a duplicate, delete the repo-level one:
gh secret delete PACKR_TOKEN
PACKR_TOKEN vs PACKR_TOKEN_PUBLISH
The original design was: use PACKR_TOKEN (read-only) for pnpm install, PACKR_TOKEN_PUBLISH (publish + scoped) for releases.
In practice this doesn't work with a single root .npmrc, because:
- Root
.npmrcreferences${PACKR_TOKEN}for the registry pnpm installANDpnpm publishboth read this same.npmrc- The workflow sets
NODE_AUTH_TOKEN=PACKR_TOKEN_PUBLISHbutpnpm publishprefers the_authTokenline from.npmrc(which resolves toPACKR_TOKEN)
Simpler setup that actually works (what's in production):
Use one token for both reads and publishes — the ci-publish token with role ci-publish, scoped to @blueforge-studio. It has ["read", "publish"] permissions, so installs work too. Set it as both PACKR_TOKEN and PACKR_TOKEN_PUBLISH org secrets:
printf '%s' '<ci-publish-token>' | gh secret set PACKR_TOKEN --org blueforge-studio
printf '%s' '<ci-publish-token>' | gh secret set PACKR_TOKEN_PUBLISH --org blueforge-studio
The scope restriction (@blueforge-studio only) is the authorization boundary. Since the token can't publish to any other scope, the read/write separation is less critical.
For true defense-in-depth, you'd need to split the .npmrc — one for install (with read token) and a temp-generated one for publish (with publish token). That's a future improvement.
Step 5: Initialize Projects
Run in each repo that consumes Packr packages:
./bin/packr-cli init --scope @blueforge-studio --registry https://api.packr.blueforge.studio
This creates/updates:
.packr.json— registry + scope config.npmrc— scope registry line +${PACKR_TOKEN}env var reference (safe to commit)
Key repos to initialize:
blueforge-org(already has .npmrc — init will standardize it)mailstack-os- Any other repo consuming
@blueforge-studio/*packages
Step 6: Update Dashboard Environment (Vercel)
The dashboard site needs the Packr OAuth App credentials for its existing web login flow.
In Vercel Dashboard → packr site → Settings → Environment Variables, set:
GITHUB_OAUTH_CLIENT_ID=Ov23ligJE4NpN7NysXzL
GITHUB_OAUTH_CLIENT_SECRET=<from GitHub → Settings → Developer settings → OAuth Apps → Packr → Client secrets>
The client secret was previously written out in full here, so it is in this repo's git history and in every clone. Treat the value that was committed as burned: generate a new secret in the OAuth App and update Vercel. The client ID above is not a secret and needs no rotation.
Or via CLI:
cd packages/site
vercel env add GITHUB_OAUTH_CLIENT_ID
vercel env add GITHUB_OAUTH_CLIENT_SECRET
Troubleshooting
"authorization required" on device/authorize
The device flow endpoints may be hitting auth middleware. Verify the registry is running the latest code with the middleware exemption for /-/v1/device/* routes.
curl -s https://api.packr.blueforge.studio/health
# Should show uptime from latest deploy
OAuth callback fails / redirect_uri mismatch
Check:
- GitHub OAuth App Authorization callback URL is
https://packr.blueforge.studio/api/auth/callback/github(NOT/cli/auth/callback) - Dashboard site has
/api/auth/callback/github/route.tsdeployed OAUTH_REDIRECT_BASE=https://packr.blueforge.studioset on Fly.ioINTERNAL_API_SECRETmatches between Fly.io registry and Vercel dashboard
Workflow publishes to wrong token
If CI publishes are failing with 403 "insufficient permission" or "token not authorized for scope":
- Check for repo-level
PACKR_TOKENshadowing the org-level one (see Step 4) - Verify the token in the secret actually has the permissions you expect — decode the JWT payload (middle base64 section) with
echo "eyJ..." | base64 -d - Check Fly.io logs:
fly logs --no-tail | grep publish:
Token expired
./bin/packr-cli login --provider github --registry https://api.packr.blueforge.studio
# Re-authenticates and refreshes the token
Keychain storage (optional)
To store tokens in macOS Keychain instead of plain file:
./bin/packr-cli config set credential-store keychain
./bin/packr-cli login --provider github --registry https://api.packr.blueforge.studio
Current Fly.io Secrets
| Secret | Status |
|---|---|
JWT_SECRET | Set |
SESSION_SECRET | Set |
INTERNAL_API_SECRET | Set |
OAUTH_GITHUB_CLIENT_ID | Set (Ov23ligJE4NpN7NysXzL) |
OAUTH_GITHUB_CLIENT_SECRET | Set |
ALLOW_REGISTRATION | Set |
DASHBOARD_ORIGIN | Set |
S3_* / BLOB_DRIVER | Set (Backblaze B2) |
PROXY_* | Set |
Token Role Reference
| Role | Permissions | Use for |
|---|---|---|
ci-readonly | read | CI install jobs, local dev installs |
ci-publish | read, publish | CI publish workflows |
maintainer | read, publish, unpublish | Developer accounts (default for OAuth login) |
admin | all | Registry administration |
Lifting a rate limit (super-admin escape hatch)
The login limiter is 10/min per IP. A packr-cli login device flow that polls
can spend that, and the operator is then locked out of the one action that
would let them fix anything — including whatever made them retry. Observed
2026-09-02.
curl -X POST https://api.packr.blueforge.studio/api/v1/admin/maintenance/rate-limit/reset \
-H "X-Super-Admin-Token: $SUPER_ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"limiter":"login","key":"203.0.113.7"}'
limiter—login,publish,admin,search, orall. An unknown name is a 400, not a silent no-op: "cleared nothing" and "worked" must not look the same to someone acting in an incident.key— the client IP to clear. Omit it to clear every bucket on that limiter.
Gated by X-Super-Admin-Token or X-Internal-Secret, like the other
maintenance/* routes. Deliberately not behind a session: an operator
locked out by the login limiter has no session to present, and gating it behind
one would make it unreachable exactly when it is needed.
Budgeted at 3 per rolling 24h (RATE_LIMIT_RESETS_PER_DAY). A reset switch
with no budget is not a safety valve, it is the removal of the limit — anyone
holding the token could clear the login limiter on every request and brute-force
behind it. The window slides, so a budget spent at 23:00 refills through the
following day rather than at midnight.
Every reset is written to the audit log as ratelimit.reset with the limiter,
key and what was cleared. The budget keeps those entries rare enough to read.
The response reports what is left:
{"cleared":{"login":1},"resetsRemaining":2,"budgetWindowHours":24}
If sessions are being invalidated rather than rate-limited, the cause is different:
SESSION_SECRETunset makes the server generate a random one at boot, so every restart silently logs everyone out while the CLI still reports a valid local session. SetSESSION_SECRETandJWT_SECRETpersistently.