Documentation

OAuth and providers

Device flow, provider setup, and custom OIDC.

Source: api.packr.blueforge.studio/-/docs/oauth

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

ProviderNameRequired Env Vars
GitHubgithubOAUTH_GITHUB_CLIENT_ID, OAUTH_GITHUB_CLIENT_SECRET
GooglegoogleOAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET
GitLabgitlabOAUTH_GITLAB_CLIENT_ID, OAUTH_GITLAB_CLIENT_SECRET, optional OAUTH_GITLAB_BASE_URL
Generic OIDCoidcOAUTH_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

  1. The CLI starts a device flow and gets a verification_uri pointing to forge-auth.
  2. The user completes SSO in their browser (MFA, organization policy, etc.).
  3. Forge-auth issues an OIDC token with the user's email and organization membership claims.
  4. Packr exchanges this for its own JWT access token.

Env Vars

VariableDescription
FORGE_AUTH_URLBase URL of the forge-auth service (e.g. https://auth.blueforge.studio)
FORGE_AUTH_CLIENT_IDOAuth client ID registered in forge-auth
FORGE_AUTH_CLIENT_SECRETOAuth client secret

Auth Mode: PACKR_AUTH_MODE

Packr supports three auth modes:

ModeDescription
standaloneLocal username/password only. No OAuth providers.
forge-authForge-auth SSO only. Password login disabled.
hybridBoth 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:

StateResponseMeaning
Pending400 authorization_pendingUser hasn't finished yet. Keep polling.
Slow down400 slow_downPolling too fast. Back off.
Expired400 expired_tokenCode expired (15 min). Start over.
Done200 { access_token }Success. Store the token.

Configuring Providers

GitHub

  1. Go to GitHub Settings → Developer settings → OAuth Apps → New OAuth App
  2. Set Authorization callback URL to https://your-dashboard/api/auth/callback/github (e.g. https://packr.blueforge.studio/api/auth/callback/github)
  3. Copy Client ID and Client Secret
OAUTH_GITHUB_CLIENT_ID=Ov23liABCDEFGH123456
OAUTH_GITHUB_CLIENT_SECRET=abc123def456...
OAUTH_REDIRECT_BASE=https://packr.blueforge.studio

Google

  1. Go to Google Cloud Console → APIs & Services → Credentials → Create OAuth 2.0 Client
  2. Set Authorized redirect URI to https://your-dashboard/api/auth/callback/google
  3. 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

  1. Go to GitLab → Preferences → Applications → Add new application
  2. Set Redirect URI to https://your-dashboard/api/auth/callback/gitlab
  3. 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

  1. Log in to the forge-auth admin console (e.g. https://auth.blueforge.studio/admin).
  2. Navigate to Applications → New Application.
  3. Set the application name to Packr Registry.
  4. Set the Redirect URI / Callback URL to:
    https://packr.blueforge.studio/api/auth/callback/forgeauth
    
  5. 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
VariableDescription
FORGE_AUTH_URLBase URL of the forge-auth service
FORGE_AUTH_CLIENT_IDClient ID from forge-auth app registration
FORGE_AUTH_CLIENT_SECRETClient secret from forge-auth app registration
PACKR_AUTH_MODESet 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 a packr_session cookie, 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/auth with success or error status.

Next: docs/07-tokens.md — Token Permission Model and Roles