Part 6: OAuth Provider Architecture
Previous: docs/05-cli.md
Overview
Packr supports OAuth 2.0 login via a pluggable provider system. Instead of embedding provider-specific code throughout the registry, each provider implements a common OAuthProvider interface. This makes it straightforward to add new providers — including custom OIDC providers such as Keycloak, Okta, or Azure AD — without modifying core auth logic.
OAuth login is primarily accessed via the OAuth device flow (RFC 8628), which is designed for headless and CI environments where opening a browser is not always possible. The CLI initiates the flow, the user completes it in a browser, and the CLI polls for the result.
OAuthProvider Interface
All providers implement this Go interface:
type OAuthProvider interface {
// Name returns the provider identifier used in URLs and config.
// Examples: "github", "google", "gitlab", "oidc"
Name() string
// AuthorizeURL builds the provider's authorization URL.
// state is a random CSRF token generated per-request.
AuthorizeURL(state string) string
// ExchangeCode exchanges an authorization code for an access token,
// then fetches the user's profile from the provider.
ExchangeCode(code string) (*OAuthUser, error)
}
type OAuthUser struct {
ProviderID string // Unique ID from the provider
Email string // Primary email
Login string // Username / handle
Name string // Display name
AvatarURL string // Profile picture URL
}
Built-in Providers
| Provider | Name | Required Env Vars |
|---|---|---|
| GitHub | github | OAUTH_GITHUB_CLIENT_ID, OAUTH_GITHUB_CLIENT_SECRET |
google | OAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET | |
| GitLab | gitlab | OAUTH_GITLAB_CLIENT_ID, OAUTH_GITLAB_CLIENT_SECRET, optional OAUTH_GITLAB_BASE_URL |
| Generic OIDC | oidc | OAUTH_OIDC_CLIENT_ID, OAUTH_OIDC_CLIENT_SECRET, OAUTH_OIDC_ISSUER_URL |
All providers also require OAUTH_REDIRECT_BASE to be set to the public dashboard URL (e.g. https://packr.blueforge.studio). The provider redirect URI is built as {OAUTH_REDIRECT_BASE}/api/auth/callback/{provider}.
Providers are auto-registered at startup if their required env vars are set. A provider is omitted if its credentials are missing, so you can run with only the providers you need.
Forge-auth Integration
Forge-auth is BlueForge Studio's internal SSO service. When enabled, it acts as an OIDC provider that fronts your organization's identity stack (GitHub, Google Workspace, or any SAML IdP). From Packr's perspective, forge-auth is a standard OIDC provider.
How it works
- The CLI starts a device flow and gets a
verification_uripointing to forge-auth. - The user completes SSO in their browser (MFA, organization policy, etc.).
- Forge-auth issues an OIDC token with the user's email and organization membership claims.
- Packr exchanges this for its own JWT access token.
Env Vars
| Variable | Description |
|---|---|
FORGE_AUTH_URL | Base URL of the forge-auth service (e.g. https://auth.blueforge.studio) |
FORGE_AUTH_CLIENT_ID | OAuth client ID registered in forge-auth |
FORGE_AUTH_CLIENT_SECRET | OAuth client secret |
Auth Mode: PACKR_AUTH_MODE
Packr supports three auth modes:
| Mode | Description |
|---|---|
standalone | Local username/password only. No OAuth providers. |
forge-auth | Forge-auth SSO only. Password login disabled. |
hybrid | Both password login and OAuth providers active. Default. |
Set with:
PACKR_AUTH_MODE=hybrid # default
PACKR_AUTH_MODE=standalone
PACKR_AUTH_MODE=forge-auth
OAuth Device Flow (RFC 8628)
The device flow allows the CLI (a "device") to initiate OAuth login without a browser. The user completes the flow on a separate device or browser tab.
Step-by-step
CLI Registry OAuth Provider
| | |
|--POST /-/v1/device/authorize->| |
| { provider: "github" } | |
| |-- generates device_code, |
| | user_code, verification_uri|
|<-- { device_code, | |
| user_code, | |
| verification_uri, | |
| expires_in: 900 } ------+ |
| | |
| [CLI prints:] | |
| "Open: https://registry/verify?code=ABCD-1234" |
| [polls every 5s...] | |
| | |
| [User opens browser, visits verification_uri] |
| | |
| |<-- GET /verify?code=ABCD-1234|
| |-- redirects to OAuth provider|
| | |
| | [User logs in with provider]
| | |
| |<-- callback with auth code --+
| |-- ExchangeCode() |
| |-- creates/updates user |
| |-- issues JWT |
| |-- marks device_code as done |
| | |
|--POST /-/v1/device/token---->| |
| { device_code } | |
|<-- { access_token, token_type: "Bearer" } ----------------+
| | |
| [CLI stores token, done] | |
Polling States
When polling POST /-/v1/device/token, the registry returns one of:
| State | Response | Meaning |
|---|---|---|
| Pending | 400 authorization_pending | User hasn't finished yet. Keep polling. |
| Slow down | 400 slow_down | Polling too fast. Back off. |
| Expired | 400 expired_token | Code expired (15 min). Start over. |
| Done | 200 { access_token } | Success. Store the token. |
Configuring Providers
GitHub
- Go to GitHub Settings → Developer settings → OAuth Apps → New OAuth App
- Set Authorization callback URL to
https://your-dashboard/api/auth/callback/github(e.g.https://packr.blueforge.studio/api/auth/callback/github) - Copy Client ID and Client Secret
OAUTH_GITHUB_CLIENT_ID=Ov23liABCDEFGH123456
OAUTH_GITHUB_CLIENT_SECRET=abc123def456...
OAUTH_REDIRECT_BASE=https://packr.blueforge.studio
- Go to Google Cloud Console → APIs & Services → Credentials → Create OAuth 2.0 Client
- Set Authorized redirect URI to
https://your-dashboard/api/auth/callback/google - Copy Client ID and Client Secret
OAUTH_GOOGLE_CLIENT_ID=123456789-abc.apps.googleusercontent.com
OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-abc123...
OAUTH_REDIRECT_BASE=https://packr.blueforge.studio
GitLab
- Go to GitLab → Preferences → Applications → Add new application
- Set Redirect URI to
https://your-dashboard/api/auth/callback/gitlab - Select scopes:
read_user,email
OAUTH_GITLAB_CLIENT_ID=abc123def456...
OAUTH_GITLAB_CLIENT_SECRET=gloas-abc123...
OAUTH_GITLAB_BASE_URL=https://gitlab.com # or your self-hosted GitLab URL
OAUTH_REDIRECT_BASE=https://packr.blueforge.studio
Generic OIDC
Supports Keycloak, Okta, Azure AD, Authentik, and any RFC 8414-compliant OIDC provider.
OAUTH_OIDC_CLIENT_ID=packr
OAUTH_OIDC_CLIENT_SECRET=super-secret
OAUTH_OIDC_ISSUER_URL=https://auth.example.com/realms/myrealm
OAUTH_REDIRECT_BASE=https://packr.blueforge.studio
# Packr will auto-discover endpoints from /.well-known/openid-configuration
Configure your OIDC provider's redirect URI to https://your-dashboard/api/auth/callback/oidc.
Keycloak Example
OAUTH_OIDC_ISSUER_URL=https://keycloak.example.com/realms/my-org
OAUTH_OIDC_CLIENT_ID=packr-registry
OAUTH_OIDC_CLIENT_SECRET=keycloak-client-secret
Okta Example
OAUTH_OIDC_ISSUER_URL=https://dev-12345.okta.com/oauth2/default
OAUTH_OIDC_CLIENT_ID=0oa1b2c3d4e5f6g7h8i9
OAUTH_OIDC_CLIENT_SECRET=okta-client-secret
Azure AD Example
OAUTH_OIDC_ISSUER_URL=https://login.microsoftonline.com/your-tenant-id/v2.0
OAUTH_OIDC_CLIENT_ID=azure-app-client-id
OAUTH_OIDC_CLIENT_SECRET=azure-client-secret
Adding a Custom Provider
Implement the OAuthProvider interface:
type MyProvider struct {
clientID string
clientSecret string
}
func (p *MyProvider) Name() string { return "myprovider" }
func (p *MyProvider) AuthorizeURL(state string) string {
return fmt.Sprintf(
"https://auth.example.com/oauth/authorize?client_id=%s&state=%s&response_type=code&scope=email+profile",
p.clientID, state,
)
}
func (p *MyProvider) ExchangeCode(code string) (*OAuthUser, error) {
// 1. POST to token endpoint to get access_token
// 2. GET user profile
// 3. Return &OAuthUser{...}
}
Register it in internal/oauth/registry.go:
if clientID := os.Getenv("MYPROVIDER_CLIENT_ID"); clientID != "" {
providers["myprovider"] = &MyProvider{
clientID: clientID,
clientSecret: os.Getenv("MYPROVIDER_CLIENT_SECRET"),
}
}
Security
State Parameter
Every authorization request includes a randomly generated state parameter (32 bytes, hex-encoded). The registry verifies the state on callback to prevent CSRF attacks. States are single-use and expire with the device code.
Device Code Expiry
Device codes and user codes expire after 15 minutes. After expiry, the user must restart the login flow. This limits the window for phishing attacks where an attacker tries to get a user to approve a stale code.
Rate Limiting
The device authorize endpoint is rate-limited to 5 requests per minute per IP. This prevents automated enumeration of user codes.
Single-Use Codes
Once a device code is exchanged for an access token, it is immediately invalidated. Replaying the same device code returns expired_token.
Account Linking
When a user logs in via OAuth for the first time, Packr looks up an existing account by email. If a match is found, the OAuth identity is linked to that account. If no match is found, a new account is created. This means a user with the same email on GitHub and Google will be linked to the same Packr account automatically.
Forge-auth Integration Setup
This section describes how to register Packr as an application in forge-auth and configure the BlueForge ecosystem SSO callback.
1. Register Packr in forge-auth Admin
- Log in to the forge-auth admin console (e.g.
https://auth.blueforge.studio/admin). - Navigate to Applications → New Application.
- Set the application name to
Packr Registry. - Set the Redirect URI / Callback URL to:
https://packr.blueforge.studio/api/auth/callback/forgeauth - Note the generated Client ID and Client Secret.
2. Configure Environment Variables on Fly.io
Set the following secrets on your Fly.io deployment:
fly secrets set \
FORGE_AUTH_URL=https://auth.blueforge.studio \
FORGE_AUTH_CLIENT_ID=<your-client-id> \
FORGE_AUTH_CLIENT_SECRET=<your-client-secret> \
PACKR_AUTH_MODE=hybrid
| Variable | Description |
|---|---|
FORGE_AUTH_URL | Base URL of the forge-auth service |
FORGE_AUTH_CLIENT_ID | Client ID from forge-auth app registration |
FORGE_AUTH_CLIENT_SECRET | Client secret from forge-auth app registration |
PACKR_AUTH_MODE | Set to hybrid to enable both password login and OAuth, or forge-auth for SSO-only |
3. Callback URL
The dashboard Next.js app handles the OAuth callback at:
https://packr.blueforge.studio/api/auth/callback/forgeauth
This route (packages/site/app/api/auth/callback/forgeauth/route.ts) handles two flows:
- Web dashboard login (
state=web): Exchanges the authorization code for a session token via the registry admin API and sets apackr_sessioncookie, then redirects to/dashboard. - CLI device flow (
state=<device_code>): Posts the code and device code to the registry's device callback endpoint, then redirects to/cli/authwith success or error status.
Next: docs/07-tokens.md — Token Permission Model and Roles