devidp

package
v0.5.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

README

contrib/devidp

devidp is a development-only OpenID Provider. It authenticates by letting a developer pick a user from a TOML roster; no credential is ever checked. It exists so contrib/oidc has an in-repo counterparty: discovery, JWKS, RS256 ID Tokens, Authorization Code with mandatory S256 PKCE, Device Authorization, UserInfo, and RP-initiated logout.

Never link this package into an application binary. pw build fails a project that imports it.

roster, err := devidp.LoadConfig("devidp.toml")
server, err := devidp.Start(ctx, "127.0.0.1:0", roster, devidp.Options{
	LoginUser: "admin", // skip the selection screen; omit for the login UI
})
defer server.Close()

credentials, err := server.RegisterClient(devidp.ClientSpec{LoopbackRedirects: true})
// server.Issuer(), credentials.ID, and credentials.Secret configure the client.

deviceCredentials, err := server.RegisterPublicDeviceClient(devidp.PublicDeviceClientSpec{})
// Device clients receive an ID only. No embedded client secret is created.

Start binds loopback only and derives the issuer from the resolved port, so the caller never has to reserve one. RegisterClient generates the client id and secret; with LoopbackRedirects it accepts any loopback callback following the RFC 8252 §7.3 loopback rule, which lets a tool register a client before it knows the application's port or callback path. Clients declared in the roster keep exact redirect URI matching.

Roster

[idp]
valid_scopes = ["admin"]     # beyond openid, profile, and email
token_ttl = "1h"             # default 1h, capped at 12h
code_ttl = "1m"              # default 1m, capped at 10m
# issuer = "http://127.0.0.1:18080"   # Start fills this from the listener
# signing_key = "signing.pem"         # default: an ephemeral RSA key per process

[users.admin]
display_name = "Administrator"
extra_scopes = ["admin"]
# subject = "admin"          # defaults to the table key
[users.admin.claims]
email = "admin@example.com"
role = "admin"
employee_id = 42

[clients.myapp]              # optional; a running tool registers its own
secret = "development-secret"
redirect_uris = ["http://127.0.0.1:8080/auth/callback"]

[clients.mydevice]
grants = ["device_code"]

Unknown keys and tables are errors. iss, sub, aud, exp, iat, nbf, auth_time, nonce, azp, and at_hash cannot be set as claims. Roster claims are always issued; profile and email additionally gate the standard claims of those scopes. A granted scope must be present in the request, in the provider scope set, and — beyond the standard scopes — in the user's extra_scopes.

Using it

pw dev starts the provider when dev.idp.enabled is set in popcornweb.toml, registers an ephemeral client, and injects AUTH_OIDC_ISSUER, AUTH_OIDC_CLIENT_ID, and AUTH_OIDC_CLIENT_SECRET into the application process. Environment values outrank TOML, so no issuer or credential belongs in a committed config file. An edited roster reloads in place through Provider.Reload, keeping the issuer and injected credentials valid.

In tests, testutil.WithIdentityProvider starts the same provider beside a TestRun server and WithLoginUser pre-selects the subject, so a login completes without driving a browser.

/end_session implements RP-initiated logout: it verifies id_token_hint against its own signing key, revokes the access tokens of that subject, and redirects to post_logout_redirect_uri. Relying parties need it — a logout that only drops the application cookie leaves this provider signed in.

Unlike a real provider, the post-logout URL needs no registration: any local target is accepted (a loopback address, localhost, or a name under .localhost). Requiring registration would put friction back into the one place this provider removes it from. Non-local targets are still refused, so it cannot become an open redirect for anything off the machine.

Refresh tokens, client credentials grants, EntraID compatibility, public clients outside Device Flow, dynamic registration, implicit and hybrid flows, consent, and any form of credential storage are intentionally excluded. Authorization codes, access tokens, and the signing key live in memory only and are destroyed by Close.

Documentation

Overview

Package devidp implements a development-only OpenID Provider.

The provider authenticates by letting a developer select a user from a TOML roster; it never checks a credential. It implements Authorization Code with mandatory S256 PKCE, RFC 8628 Device Authorization, RS256 ID Tokens published through a JWKS endpoint, discovery metadata, and UserInfo.

The package is host-only tooling. It must never be imported by an application binary: pw build rejects a project that does. Everything it issues lives in memory and dies with the process.

Index

Constants

View Source
const (
	DefaultTokenTTL = time.Hour
	DefaultCodeTTL  = time.Minute
)

Defaults applied when the roster leaves a lifetime unset.

View Source
const (
	GrantAuthorizationCode = "authorization_code"
	GrantDeviceCode        = "device_code"
)

Variables

View Source
var (
	// ErrConfig reports an unusable roster file or Config value.
	ErrConfig = errors.New("devidp: invalid configuration")
	// ErrUnknownUser reports a subject that is absent from the roster.
	ErrUnknownUser = errors.New("devidp: unknown user")
)
View Source
var ErrClosed = errors.New("devidp: provider is closed")

ErrClosed reports use of a provider after Close.

Functions

This section is empty.

Types

type Client

type Client struct {
	ID           string
	Secret       string
	RedirectURIs []string
	ValidScopes  []string
	GrantTypes   []string
	// LoopbackRedirects accepts any loopback redirect URI instead of matching
	// RedirectURIs exactly. Only a client registered by the running tool may
	// set it; see RegisterClient.
	LoopbackRedirects bool
}

Client is a relying party allowed to obtain tokens.

type ClientSpec

type ClientSpec struct {
	// ID is generated when empty.
	ID string
	// RedirectURIs are matched exactly unless LoopbackRedirects is set.
	RedirectURIs []string
	// LoopbackRedirects accepts any loopback callback, which lets a tool
	// register before it knows the application port or callback path.
	LoopbackRedirects bool
	ValidScopes       []string
}

ClientSpec describes a client the running tool registers for itself.

type Config

type Config struct {
	// Issuer is the absolute base URL. Start fills it from the listener when empty.
	Issuer      string
	ValidScopes []string
	TokenTTL    time.Duration
	CodeTTL     time.Duration
	// SigningKey signs ID Tokens. New generates an ephemeral key when nil.
	SigningKey *rsa.PrivateKey
	Clients    []Client
	Users      []User
	// contains filtered or unexported fields
}

Config is the resolved provider configuration.

func LoadConfig

func LoadConfig(path string) (Config, error)

LoadConfig reads a roster file. Paths inside it resolve from its directory.

func ParseConfig

func ParseConfig(source []byte, base string) (Config, error)

ParseConfig parses roster bytes, resolving relative paths from base.

type Credentials

type Credentials struct {
	ID     string
	Secret string
}

Credentials are the generated secrets for a registered client.

type Options

type Options struct {
	Now    func() time.Time
	Random io.Reader
	Logf   func(format string, args ...any)
	// LoginUser pre-selects a subject so authorization skips the login screen.
	LoginUser string
}

Options tunes provider behavior. The zero value is production-shaped for a development tool: real clock, crypto/rand, no logging, manual login.

type Provider

type Provider struct {
	// contains filtered or unexported fields
}

Provider serves the development OpenID Provider endpoints.

func New

func New(config Config, options Options) (*Provider, error)

New builds a provider from a validated configuration.

The environment lock is here rather than only in Start, because Handler is exported and a provider built here serves the same endpoints whether or not this package opened the listener. Gating only Start left the whole thing reachable to anyone who mounted Handler on their own mux — which is a documented way to use this package — and a plain `go build` of that application published an OpenID Provider that issues a token for any subject in the roster to anyone who asks.

`pw build` refuses an application that imports this package, and that remains the first line of defence. It is a toolchain check, though, and a Dockerfile or CI job that calls `go build` directly never runs it. This one travels with the code.

func (*Provider) Close

func (p *Provider) Close() error

Close destroys signing key material and every pending authorization.

func (*Provider) Endpoint

func (p *Provider) Endpoint(path string) string

Endpoint returns an absolute provider URL, for example "/authorize".

func (*Provider) Handler

func (p *Provider) Handler() http.Handler

Handler serves the provider endpoints under the issuer base path.

func (*Provider) Issuer

func (p *Provider) Issuer() string

Issuer returns the base URL every discovery URL is built from.

func (*Provider) LoginUser

func (p *Provider) LoginUser() string

LoginUser reports the pre-selected subject, if any.

func (*Provider) RegisterClient

func (p *Provider) RegisterClient(spec ClientSpec) (Credentials, error)

RegisterClient adds an ephemeral client and returns its generated credentials. The secret exists only in memory and is not recoverable after Close.

func (*Provider) RegisterPublicDeviceClient

func (p *Provider) RegisterPublicDeviceClient(spec PublicDeviceClientSpec) (Credentials, error)

RegisterPublicDeviceClient adds an ephemeral RFC 8628 public client. It returns no secret because a value embedded in a constrained device cannot authenticate that device.

func (*Provider) Reload

func (p *Provider) Reload(config Config) error

Reload replaces the roster and scope set in place. Registered clients, the issuer, and the signing key survive, so an edited roster reaches the running application without restarting it or reissuing its injected credentials.

A pre-selected login user that left the roster is cleared rather than kept as a dangling subject.

func (*Provider) SetLoginUser

func (p *Provider) SetLoginUser(subject string) error

SetLoginUser pre-selects a subject so the login screen is skipped. An empty subject restores manual selection.

func (*Provider) Users

func (p *Provider) Users() []User

Users returns the roster in login-screen order.

type PublicDeviceClientSpec

type PublicDeviceClientSpec struct {
	ID          string
	ValidScopes []string
}

type Server

type Server struct {
	*Provider
	// contains filtered or unexported fields
}

Server is a provider bound to a listener.

func Start

func Start(ctx context.Context, addr string, config Config, options Options) (*Server, error)

Start listens on addr, derives the issuer from the resolved address when the configuration leaves it empty, and serves the provider.

addr must be a loopback address unless the configuration carries an explicit issuer, because the provider performs no authentication.

func (*Server) Close

func (s *Server) Close() error

Close stops the listener and destroys provider state.

type User

type User struct {
	Key         string
	Subject     string
	DisplayName string
	ExtraScopes []string
	Claims      map[string]any
}

User is one selectable development identity.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL