goapiauth

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Oct 22, 2026 License: MIT Imports: 16 Imported by: 0

README

goapiauth

Authentication inside a Go web API whose users log in through an OIDC provider. It verifies the provider's RFC 9068 JWT access tokens, and keeps verifying them while the provider is down. Its tokenorsession package admits either a bearer token or a gobrowserlogin session, for an API that serves its own browser front end.

The command-line half of the same login is goclilogin, which logs a CLI in with the device authorization grant and sends the token this package checks.

Install

go get github.com/datapointchris/goapiauth

Starting from scratch

An API that only CLIs and services call:

config := goapiauth.Config{
 Issuer:        "https://auth.example.com",
 PersonPrefix:  clientid.PersonPrefix("acme"),  // acme-cli-<host>
 ServicePrefix: clientid.ServicePrefix("acme"), // acme-svc-<name>
}
verifier, err := goapiauth.NewConnecting(config)
if err != nil {
 return err
}
go verifier.Run(ctx, logger)

mux := http.NewServeMux()
mux.Handle("GET "+goapiauth.ProtectedResourcePath, goapiauth.ProtectedResourceMetadata{
 Resource: "https://acme.example.com",
 Issuer:   config.Issuer,
})
mux.HandleFunc("GET /api/v1/reports", listReports)

routes := map[string][]string{"acme.read": {"GET /api/v1/reports"}}
handler := goapiauth.RequireBearer(verifier, logger, "/health")(
 goapiauth.LimitServices(mux, routes, logger))

An API that also serves a browser front end, which logs in through gobrowserlogin:

handler := tokenorsession.Require(verifier, sessions, logger,
 "/health", "/api/auth/login", "/api/auth/callback")(mux)

A handler reads who called with goapiauth.IdentityFrom(r.Context()).

Which function answers which question

Question Answer
Is this token one of my clients', signed by my provider? Verifier.Verify
Should the API start while the provider is down? NewConnecting, and Ready for a readiness probe
Does this request carry an accepted token? RequireBearer
Does it carry a token or a browser session? tokenorsession.Require
Who called? IdentityFrom
May this service reach this route? LimitServices
Is this caller in a group? RequireGroup
Where does a CLI learn which provider to log in through? ProtectedResourceMetadata
What client id does a product's CLI or service register? clientid.PersonPrefix, clientid.ServicePrefix

Verify — the client id decides whose token it is

A token is refused unless it is signed with RS256 by a key the provider publishes, typed at+jwt, from Config.Issuer and unexpired. The type check keeps out an ID token, which carries the same signature and issuer and is handed to the client. The audience claim is not checked, because some providers leave it empty on a device grant's token. The client id's prefix is what keeps a token issued for another product out, so Config refuses a prefix clientid could not have built.

A token from a PersonPrefix client is a person's and must carry sub. One from a ServicePrefix client is a service's and must carry a non-empty scp. A missing sub cannot decide which a token is. Some providers leave it off a client-credentials token, but RFC 9068 requires one, and a release that added it would make a service look like a person. An empty ServicePrefix refuses every service token.

The keys outlive an outage

The keys are read at startup and held. Held keys past an age the package doc gives are read again, which bounds how long a key the provider stopped publishing still verifies. A token they verify is answered without waiting on that read. When the read fails they go on verifying, because a provider that cannot be read has not rotated them out. An API that dropped its keys at a fixed age would refuse every token a few minutes into an outage, and every caller would see the API down with it.

A token no held key verifies makes the Verifier read the keys again, which covers a key the provider has just rotated in. Those reads are spaced by a minimum gap, so anyone able to reach the API cannot turn a stream of forged tokens into a stream of reads. Inside the gap after a failed read, such a token is refused as the outage it met, never as a bad token.

Refusals — one body, and a code to branch on

Every refusal answers {"error": <status text>, "message": <reason>, "code": <code>}. The reason names what is wrong with the request, never which check a token failed. The codes are exported as the Code constants:

Status code When
401 missing_token no credential
401 invalid_token a token the Verifier refused
503 identity_provider_unavailable the provider cannot be read, and no held key verifies the token
403 outside_service_scope a service outside its scopes
403 outside_group a caller outside the group RequireGroup names
403 missing_csrf_header a session cookie without gobrowserlogin's CSRF header
503 session_store_unavailable a session that cannot be read

A 401 carries the RFC 6750 WWW-Authenticate: Bearer challenge.

tokenorsession — a token never falls through to a session

A request carrying a bearer token is judged on the token alone, and the session is never consulted. Falling through would let a caller pair a junk token with a stolen cookie. A request with no bearer token is judged on its session, and its Identity carries the session's subject and groups.

What it deliberately does not do

  • No token introspection. A JWT access token verifies offline, and an introspection call per request would make the provider a dependency of every request.
  • No algorithm other than RS256. A token naming another is refused before any key is tried.
  • No audience check. Some providers leave the claim empty on a device grant's token. The client id prefix does that job.

Documentation

Overview

Package goapiauth authenticates requests to a Go web API whose users log in through an OIDC provider.

A command-line client logs in with the device authorization grant, and a service with the client-credentials grant. Each then sends the provider's access token as a bearer token. Verifier checks it as an RFC 9068 JWT access token: signed by a key the provider publishes, typed at+jwt, from Config.Issuer, unexpired, and issued to one of this API's clients. The audience claim is not checked, because some providers leave it empty on a device grant's token. The client id's prefix is what keeps a token issued for another product out. Package clientid spells those prefixes.

The provider's keys

The keys are read once and held. A token no held key verifies makes the Verifier read them again, which covers a key the provider has just rotated in. Those reads are at least 30 seconds apart, so a stream of forged tokens costs the provider one read per gap. Held keys older than five minutes are read again too, which bounds how long a key the provider stopped publishing still verifies. A token they verify is answered from them without waiting on that read, and when the read fails they go on verifying. An outage of the provider does not become an outage of the API, because a provider that cannot be read has not rotated its keys out.

Requests

RequireBearer admits a request carrying a token the Verifier accepts, and puts the caller's Identity on the request's context, where IdentityFrom reads it. A refusal answers {"error": status text, "message": reason, "code": one of the Code constants}, and a client branches on the code. LimitServices keeps a service client to the routes its scopes list, and RequireGroup keeps a route to the members of one group.

The tokenorsession package admits a bearer token or a gobrowserlogin session, for an API that serves its own browser front end. It alone imports gobrowserlogin.

ProtectedResourceMetadata serves the RFC 9728 document a CLI reads to learn which provider to log in through. Both middlewares pass ProtectedResourcePath without a credential.

Index

Constants

View Source
const (
	// CodeMissingToken is a 401 for a request carrying no credential.
	CodeMissingToken = "missing_token"

	// CodeInvalidToken is a 401 for a bearer token the Verifier refused.
	CodeInvalidToken = "invalid_token"

	// CodeIdentityProviderUnavailable is a 503 for a token that could not be
	// checked, because the provider cannot be read and no held key verifies it.
	CodeIdentityProviderUnavailable = "identity_provider_unavailable"

	// CodeOutsideServiceScope is a 403 for a service client reaching a route
	// none of its scopes lists.
	CodeOutsideServiceScope = "outside_service_scope"

	// CodeOutsideGroup is a 403 for a caller outside the group RequireGroup
	// names.
	CodeOutsideGroup = "outside_group"

	// CodeMissingCSRFHeader is a 403 for a session cookie sent without
	// gobrowserlogin's CSRF header.
	CodeMissingCSRFHeader = "missing_csrf_header"

	// CodeSessionStoreUnavailable is a 503 for a session cookie whose session
	// could not be read.
	CodeSessionStoreUnavailable = "session_store_unavailable"
)

The code a refusal carries beside its error and message, for a client that branches on why it was refused. Every refusal goapiauth and tokenorsession write carries one of these.

View Source
const ProtectedResourcePath = publicpath.Metadata

ProtectedResourcePath is where RFC 9728 puts an API's metadata, for an API served at the root of its host. It is public: a CLI reads it before it holds a token, to learn which identity provider to log in through.

Variables

View Source
var ErrProviderUnavailable = errors.New("identity provider unavailable")

ErrProviderUnavailable is the identity provider not answering, or answering with no usable keys, as distinct from a token that is bad. A caller holding a good token cannot fix it by logging in again.

View Source
var ErrUnauthorized = errors.New("unauthorized")

ErrUnauthorized is returned for every rejected token. The reason is wrapped for the API's log rather than sent to the caller, so a probe cannot learn which check it failed.

Functions

func BearerToken

func BearerToken(header string) string

BearerToken is the credential in an Authorization header, or "" when the header is absent or names another scheme.

func LimitServices

func LimitServices(mux *http.ServeMux, routes map[string][]string, log *slog.Logger) http.Handler

LimitServices passes a request to mux unless its Identity is a service client and none of that client's scopes lists the pattern mux routes it to. That request is a 403. routes maps each scope to the ServeMux patterns it reaches, written as they were registered: "POST /api/v1/reports/{$}".

The pattern comes from mux itself, so a HEAD is judged as the GET route it reaches. A path no listed pattern serves, a missing route included, is a 403 to a service rather than a 404.

func RequireBearer

func RequireBearer(verifier TokenVerifier, log *slog.Logger, public ...string) func(http.Handler) http.Handler

RequireBearer passes a request to next only when it carries a bearer token verifier accepts, and puts the token's Identity on its context. A request for one of the public paths, or for ProtectedResourcePath, passes with none: a CLI reads the metadata before it holds a token.

A missing or rejected token is a 401 carrying the RFC 6750 Bearer challenge, and a provider that cannot be read is a 503. Each is logged with its reason, which the body does not carry. A request whose context ended before its token was verified is a 503 too: a deadline the API set ends it as surely as a caller leaving, and that caller is still waiting.

func RequireGroup

func RequireGroup(group string, next http.Handler) http.Handler

RequireGroup passes a request to next only when its Identity is in group, and refuses every other with a 403.

func WithIdentity

func WithIdentity(ctx context.Context, identity Identity) context.Context

WithIdentity is ctx carrying identity, as RequireBearer leaves it. A session middleware, or a development server running none, sets it the same way.

Types

type Config

type Config struct {
	// Issuer is the provider's issuer URL, as its discovery document names it.
	Issuer string

	// PersonPrefix starts the client id of every person's CLI the API accepts:
	// clientid.PersonPrefix("acme") for acme-cli-<host>. The audience claim is
	// not checked, so the prefix is what keeps a token issued to another
	// product's CLI out.
	PersonPrefix string

	// ServicePrefix starts the client id of every client-credentials client the
	// API accepts: clientid.ServicePrefix("acme") for acme-svc-<name>. Empty
	// refuses every service token.
	ServicePrefix string
}

Config names the provider an API trusts and the clients whose tokens it accepts.

type Connecting

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

Connecting is a TokenVerifier that has not yet read the identity provider. Until Run has built its Verifier it refuses every token as ErrProviderUnavailable. A provider that is down when the API starts then holds back only the requests that need a token, and the rest of the API carries on.

func NewConnecting

func NewConnecting(config Config) (*Connecting, error)

NewConnecting is a Connecting for config, which it checks as NewVerifier does. Run starts it.

func (*Connecting) Ready

func (c *Connecting) Ready() error

Ready is nil once Run has built the Verifier, and before then the last reason it could not.

func (*Connecting) Run

func (c *Connecting) Run(ctx context.Context, log *slog.Logger)

Run builds the Verifier, retrying after each failure with a wait that doubles from firstRetry to lastRetry, until it succeeds or ctx ends. A discovery document naming another issuer is retried too, because a provider still starting can serve one, and every retry is logged.

func (*Connecting) Verify

func (c *Connecting) Verify(ctx context.Context, raw string) (Identity, error)

Verify verifies raw with the Verifier Run built, and refuses it as ErrProviderUnavailable before there is one.

type Identity

type Identity struct {
	// Subject is the person. A service's token carries none.
	Subject string

	// ClientID is the client the token was issued to. A session carries none.
	ClientID string

	// Service is set for a service client's token, which carries Scopes and no
	// Subject. LimitServices keeps it to the routes Scopes list.
	Service bool
	Scopes  []string

	// Groups is the person's groups: a token's groups claim, or the groups a
	// gobrowserlogin session holds. RequireGroup reads it.
	Groups []string
}

Identity is what a verified token or a browser session establishes about the caller.

func IdentityFrom

func IdentityFrom(ctx context.Context) (Identity, bool)

IdentityFrom is the identity established for the request.

type ProtectedResourceMetadata

type ProtectedResourceMetadata struct {
	// Resource is the API's URL. A client refuses the document unless it
	// equals the URL the client fetched it for (§ 3.3), so an API behind a
	// proxy that terminates TLS names its public URL here. Empty names the
	// URL each request reached, for an API served directly under several
	// names. That reads the scheme from the connection, so behind a TLS proxy
	// it says http.
	Resource string

	// Issuer is the provider whose tokens the API accepts.
	Issuer string
}

ProtectedResourceMetadata serves the RFC 9728 document a CLI reads to learn which provider to log in through. Both fields are URLs, so a call names each one: a transposed pair serves a document every client refuses.

func (ProtectedResourceMetadata) ServeHTTP

type TokenVerifier

type TokenVerifier interface {
	Verify(ctx context.Context, raw string) (Identity, error)
}

TokenVerifier is what RequireBearer checks a token with, as *Verifier and *Connecting do.

type Verifier

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

Verifier checks token signatures against the issuer's published keys and the claims against Config.

func NewVerifier

func NewVerifier(ctx context.Context, config Config) (*Verifier, error)

NewVerifier reads the issuer's discovery document and then the keys it publishes, so a Verifier that exists can verify a token. A provider that cannot be read is ErrProviderUnavailable. An API that should start while the provider is down uses NewConnecting.

The prefixes may not overlap. Otherwise one client would match both, and which kind it was would depend on the order they are checked in.

func (*Verifier) Verify

func (v *Verifier) Verify(ctx context.Context, raw string) (Identity, error)

Verify checks the header type, the signature, and then every claim the API relies on. A bad token is ErrUnauthorized and a provider whose keys cannot be read is ErrProviderUnavailable, each with the reason wrapped for logging. ctx ending while the keys are read returns ctx's own error.

A person's token must carry a subject. A service's must carry scopes. A missing subject cannot decide which a token is: some providers leave sub off a client-credentials token, but RFC 9068 requires one, and a release that adds it would make a service look like a person. So the client id decides.

Directories

Path Synopsis
Package authtest is a stand-in for the identity provider an API trusts, for the API's tests.
Package authtest is a stand-in for the identity provider an API trusts, for the API's tests.
Package clientid spells the OAuth client ids a product registers with its identity provider: one for each person's machine and one for each service.
Package clientid spells the OAuth client ids a product registers with its identity provider: one for each person's machine and one for each service.
internal
publicpath
Package publicpath decides which requests goapiauth's middleware passes with no credential.
Package publicpath decides which requests goapiauth's middleware passes with no credential.
refusal
Package refusal writes the body every request goapiauth turns away answers with.
Package refusal writes the body every request goapiauth turns away answers with.
Package tokenorsession admits a request to a Go web API that carries either a bearer token goapiauth verifies or a gobrowserlogin session.
Package tokenorsession admits a request to a Go web API that carries either a bearer token goapiauth verifies or a gobrowserlogin session.

Jump to

Keyboard shortcuts

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