oidc

package
v0.0.0-...-90d4bd1 Latest Latest
Warning

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

Go to latest
Published: Aug 27, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package oidc is the whole of this add-on's behaviour, written so that it can be run and tested off wasm.

Why the logic is not in package main

A LinkCtrl add-on is a wasip1 reactor: the host instantiates it, calls an exported function, and the only way in or out is the generated SDK. Every SDK function compiles for any GOOS and answers "the host is not there" off wasip1, which is honest and untestable — a test that called sdk.NetworkFetch would assert on that sentence and nothing else.

So this package reaches the host through Host, an interface with one method per SDK function it uses. Package main implements it in eleven lines of forwarding under a wasip1 build tag; the tests implement it with a fake that serves a real identity provider's documents and a real signing key. The behaviour under test is therefore the behaviour that ships, and the part that is not covered is the forwarding, which has nothing in it to get wrong.

The records are written out here

LinkCtrl's ABI records are a documented JSON shape and not a Go type another repository can import — the SDK carries the functions and the statuses, and deliberately not the records, so that an add-on declares the fields it reads and ignores the rest. That is the publisher's position the first-party examples take and this add-on takes it too. Every struct below is one record from docs/addon-abi.md, with the fields this add-on actually uses.

Index

Constants

View Source
const (
	// OutcomeOK means a response arrived, whatever its status code was.
	OutcomeOK = "ok"
	// OutcomeUnconfigured means the operator has named no origin for this add-on.
	// It is the ordinary state of a freshly installed copy and gets its own page.
	OutcomeUnconfigured = "unconfigured"
	// OutcomeOriginRefused means the URL's origin is not one the operator named —
	// the case an issuer whose token endpoint lives on a second hostname produces,
	// and the one worth naming a fix for.
	OutcomeOriginRefused = "origin_refused"
)

The fetch outcomes this add-on branches on. The vocabulary is closed and larger than this; everything not named here is reported to the operator by its own word rather than flattened, because the host publishes the same word as a counter label and a log line an operator can match against a dashboard is worth more than a sentence this add-on invented.

View Source
const (
	LevelDebug = sdk.LevelDebug
	LevelInfo  = sdk.LevelInfo
	LevelWarn  = sdk.LevelWarn
	LevelError = sdk.LevelError
)

The log levels, re-exported so that this package does not import the SDK at every call site for a string constant.

View Source
const (
	// SettingIssuer is the provider's issuer URL — the `iss` this add-on will
	// insist an ID token carries, and the base the discovery document is fetched
	// from. Exact string comparison, per OpenID Connect Core 3.1.3.7.
	SettingIssuer = "issuer"
	// SettingClientID is the client identifier the provider issued.
	SettingClientID = "client_id"
	// SettingClientSecret is the client secret, declared `secret` so the manager
	// does not echo it back. It is sent as a form field, because the ABI carries
	// no request header and client_secret_basic is therefore unreachable.
	SettingClientSecret = "client_secret"
	// SettingRedirectURI is the absolute URL of this add-on's callback route.
	//
	// **It is an operator's setting because it has to be.** The host knows its own
	// public base URL — LINKCTRL_APP_BASE_URL — and no ABI record carries it: the
	// request record has no Host header, no scheme and no absolute form, and Path
	// is relative to this add-on's prefix by design. So an add-on cannot construct
	// the redirect_uri it must send to the provider, and the operator types a
	// value their instance already knows.
	SettingRedirectURI = "redirect_uri"
	// SettingProviderOrigins is the origin-marked setting: the whole of how a
	// destination reaches the host. It carries no default and no options, and it
	// is the reason this add-on reaches nothing until it is configured.
	SettingProviderOrigins = "provider_origins"
	// SettingScopes is the scope string. `openid` is added if the operator drops it.
	SettingScopes = "scopes"
	// SettingAfterSignIn is where a minted session lands.
	SettingAfterSignIn = "after_sign_in"
	// SettingAfterLink is where a completed link lands.
	SettingAfterLink = "after_link"
	// SettingRequireVerifiedEmail refuses an assertion whose email the provider
	// did not verify. Off by default: the host matches on subject and issuer and
	// never on an email address, so this changes nothing about which account is
	// reached — it is an operator's policy about the claim, not a defence.
	SettingRequireVerifiedEmail = "require_verified_email"
	// SettingClockSkewSeconds is how far out the provider's clock may be.
	SettingClockSkewSeconds = "clock_skew_seconds"
	// SettingDiscoveryTTLSeconds is how long a discovery document and a key set
	// are kept in this add-on's own schema before they are fetched again.
	SettingDiscoveryTTLSeconds = "discovery_ttl_seconds"
)

The settings this add-on declares. Every one of these is a name in addon.json and a row the operator fills in on the Add-on manager's detail page, and the constants are here so the manifest and the code cannot disagree about a spelling. A test reads addon.json and asserts that this list and its settings array are the same set.

View Source
const (
	PathIndex    = "/"
	PathStart    = "/start"
	PathLink     = "/link"
	PathCallback = "/callback"
)

The paths this add-on serves, inside its own prefix. A request arrives with its path already relative to `/addons/oidc/`, so the module never learns which prefix it was mounted under — which is deliberate: the prefix is its name and it knows its name.

View Source
const (
	// ModeSignIn asserts the identity to the host and asks for a session.
	ModeSignIn = "signin"
	// ModeLink connects the identity to whoever is already signed in.
	ModeLink = "link"
)

The two things a flow can be. The mode is stored rather than inferred from whether somebody is signed in at the callback, because those are two different moments: a session that expired mid-flow would silently turn a link into a sign-in, and one that began mid-flow would turn a sign-in into a link.

View Source
const CookieFlow = "oidc_flow"

CookieFlow is the cookie that carries the handle to a flow.

The name begins with this add-on's own name and an underscore, which the manifest declares as a `cookie_prefixes` entry and which is what makes the namespace this add-on's in both directions: the host will not hand it a cookie outside it and will not let it set one. What is in the cookie is a random handle and nothing else — the `state`, the `nonce` and the PKCE verifier are in the schema, where a visitor cannot choose them.

View Source
const DiscoveryPath = "/.well-known/openid-configuration"

DiscoveryPath is what is joined onto the issuer. OpenID Connect Discovery puts it after the issuer's own path, not at the root of the host, which matters for an issuer like https://example.com/realms/main.

View Source
const FlowTTL = 600

FlowTTL is how long a visitor has between leaving for the provider and coming back. Ten minutes: long enough for a password, a second factor and a consent screen, short enough that an abandoned flow is not a row somebody can come back to tomorrow.

View Source
const Name = "oidc"

Name is this add-on's name, and it is three things at once: the directory an operator installs it into, the Postgres schema the host gives it, and the route prefix `/addons/oidc/` its pages are served under. The host derives all three from the manifest's `name`, so this constant and that field are one fact in two files and a test asserts they agree.

View Source
const Version = "0.1.0"

Version is this add-on's own release, which the ABI is explicit is its author's business and not the product's: only the boundary is versioned there. It is in the manifest, on the status page and in CHANGELOG.md, and a test asserts the first two agree.

Variables

View Source
var ErrFlowGone = errors.New("this sign-in did not start here, or it has already been used")

ErrFlowGone is a callback whose flow is not there: never started on this instance, already spent, or expired. One error for all three deliberately — telling a caller which would tell them whether a handle they guessed exists.

View Source
var ErrFormPost = errors.New("this provider supports only response_mode=form_post, " +
	"which arrives as a cross-site POST navigation and is refused by LinkCtrl before " +
	"an add-on is entered; OpenID Connect's authorization-code default is what works here")

ErrFormPost is the one provider configuration this product cannot be used with, and it is named rather than discovered.

LinkCtrl's application tree refuses every cross-site request that uses an unsafe method — `Sec-Fetch-Site: cross-site` and `same-site` are both 403, before the module is entered — so a `form_post` callback is a POST navigation that never arrives. There is no exemption: not a trusted origin the manifest names, not a declared callback path. A provider that offers only `form_post` is a provider to plan around before choosing it.

SettingNames is every declared setting, in manifest order. The manifest test compares against this.

Functions

func Handle

func Handle(h Host) int32

Handle is the whole of a request, and it is what package main's export calls.

It answers exactly once. A handler that returns without writing is a failure the host answers as one — there is no implicit empty page — and writing twice is ErrInvalid, so every path below produces one Response and this function is the only place that writes it.

func Now

func Now(h Host) time.Time

Now is the host's wall clock. Falls back to the module's own time.Now, which the ABI documents as the same clock, so a host that refuses the function does not stop a sign-in.

func Random

func Random(h Host) (string, error)

Random is 32 bytes from the operating system's entropy, through the host, as base64url.

sdk.RandomBytes rather than crypto/rand, which the ABI says are the same source and the same bytes. The reason to use the documented spelling is that this is the one place where "the same bytes" has ever been false: until ABI 0.1.1 the runtime's default random source was a compile-time constant, so every module on every deployment drew the same nonce. Reading the ABI's own function is what makes the contract about entropy something this add-on relies on out loud.

Types

type Claim

type Claim struct {
	Subject       string   `json:"subject"`
	Issuer        string   `json:"issuer"`
	Email         string   `json:"email,omitempty"`
	EmailVerified bool     `json:"email_verified,omitempty"`
	DisplayName   string   `json:"display_name,omitempty"`
	Groups        []string `json:"groups,omitempty"`
}

Claim is the ABI's SessionClaim record: this add-on's assertion that somebody authenticated. It is a claim and not a session — the host decides whether an account exists for the subject and how long the session lives.

type Config

type Config struct {
	Issuer               string
	ClientID             string
	ClientSecret         string
	RedirectURI          string
	Scopes               string
	AfterSignIn          string
	AfterLink            string
	RequireVerifiedEmail bool
	ClockSkew            int
	DiscoveryTTL         int
}

Config is this add-on's settings, read afresh for one invocation.

Read once at the top of a call and never again, which is what the ABI asks for: a value is re-read at every config_get so that an operator's save reaches a module without a restart, and two reads inside one invocation that straddle a save can differ. A flow that compared a redirect_uri it built at the start against one it read at the end would be comparing two moments.

func LoadConfig

func LoadConfig(h Host) (Config, error)

LoadConfig reads every setting and refuses a configuration that cannot work.

type Cookie struct {
	Name   string `json:"name"`
	Value  string `json:"value"`
	MaxAge int    `json:"max_age,omitempty"`
}

Cookie is one entry of HTTPResponse's set_cookie array. MaxAge is seconds: zero is a session cookie, negative deletes, and the host refuses anything over 400 days. The name must begin with one of the manifest's cookie_prefixes.

type Discovery

type Discovery struct {
	Issuer                string   `json:"issuer"`
	AuthorizationEndpoint string   `json:"authorization_endpoint"`
	TokenEndpoint         string   `json:"token_endpoint"`
	JWKSURI               string   `json:"jwks_uri"`
	ChallengeMethods      []string `json:"code_challenge_methods_supported"`
	ResponseModes         []string `json:"response_modes_supported"`
}

Discovery is the provider's metadata document, in the fields this add-on uses.

func (Discovery) CheckResponseMode

func (d Discovery) CheckResponseMode() error

CheckResponseMode refuses a provider that cannot send a GET callback.

Only refused when the provider *advertises* its modes and `query` is not among them: response_modes_supported is optional, and a document that omits it says nothing, since `query` is the authorization-code flow's default.

type ErrUnconfigured

type ErrUnconfigured struct {
	Missing []string
}

ErrUnconfigured is what LoadConfig answers when a setting this add-on cannot run without is empty. It carries the operator-facing sentence rather than a code, because the page it ends up on is read by the person who can fix it.

func (*ErrUnconfigured) Error

func (e *ErrUnconfigured) Error() string

type Expectations

type Expectations struct {
	Issuer   string
	ClientID string
	Nonce    string
	Now      time.Time
	Skew     time.Duration
}

Expectations is what a token has to agree with. Every field is required — there is no zero value that means "do not check", because a check that can be skipped by leaving a struct field unset is a check that gets skipped.

type FetchError

type FetchError struct {
	URL     string
	Outcome string
	Status  int
}

FetchError is an outbound request that did not produce a document.

Outcome is the host's own word, from the closed vocabulary, and it is kept verbatim: an operator sees the same string as the `outcome` label of linkctrl_addon_fetch_total, so a page of this add-on's that says `origin_refused` is a page they can match against their dashboard. Status is the origin's own when the outcome was `ok` and the code was not a success.

func (*FetchError) Advice

func (e *FetchError) Advice() string

Advice is what an operator can do about this outcome, in one sentence, or "".

Written here rather than at the two call sites because the fix for each outcome is a property of the outcome. Only the ones this add-on's own shape can explain are covered; anything else is reported by its word, which is the word the host's log and metric use.

func (*FetchError) Error

func (e *FetchError) Error() string

type FetchRequest

type FetchRequest struct {
	URL    string `json:"url"`
	Method string `json:"method,omitempty"`
	Body   string `json:"body,omitempty"`
}

FetchRequest is the ABI's FetchRequest record: a URL whose origin the operator authorized, a method from a closed pair, and a form-encoded body. There is no header map, which is why the token exchange below is client_secret_post.

type FetchResponse

type FetchResponse struct {
	Outcome     string `json:"outcome"`
	Status      int    `json:"status,omitempty"`
	ContentType string `json:"content_type,omitempty"`
	Body        string `json:"body,omitempty"`
	BodyBase64  bool   `json:"body_base64,omitempty"`
}

FetchResponse is the ABI's FetchResponse record. Outcome is the first thing to read: everything else is empty unless it is OutcomeOK, and Status is the origin's own — a 404 or a 500 is still an `ok` outcome.

type Flow

type Flow struct {
	Handle   string
	Mode     string
	State    string
	Nonce    string
	Verifier string
	Issuer   string
}

Flow is what has to survive the visitor's trip to the provider.

type Host

type Host interface {
	Log(level, message string) error
	ConfigGet(key string) (string, error)
	RandomBytes(count int32) ([]byte, error)
	TimeNow() (string, error)
	StorageQuery(sql string, args []byte) ([]byte, error)
	StorageExec(sql string, args []byte) error
	HTTPRequestRead() ([]byte, error)
	HTTPResponseWrite(response []byte) error
	SessionContextRead() ([]byte, error)
	SessionMint(claim []byte) ([]byte, error)
	IdentityLink(claim []byte) error
	NetworkFetch(request []byte) ([]byte, error)
}

Host is every host function this add-on calls, and nothing else.

One method per SDK function, with the SDK's own signature, so that the wasm implementation is forwarding and the fake in the tests is the only other one that can exist. The errors are the SDK's sentinels — sdk.ErrDenied, sdk.ErrNotFound, sdk.ErrInvalid, sdk.ErrNotAvailable, sdk.ErrInternal — and this package compares with errors.Is, so a fake that answers the wrong one fails the test rather than passing it.

type IDToken

type IDToken struct {
	Issuer        string   `json:"iss"`
	Subject       string   `json:"sub"`
	Audience      audience `json:"aud"`
	AuthorizedTo  string   `json:"azp"`
	Expiry        int64    `json:"exp"`
	IssuedAt      int64    `json:"iat"`
	Nonce         string   `json:"nonce"`
	Email         string   `json:"email"`
	EmailVerified verified `json:"email_verified"`
	Name          string   `json:"name"`
	Groups        []string `json:"groups"`
}

IDToken is the claim set this add-on reads out of a verified ID token.

The fields are the ones OpenID Connect Core requires plus the three this add-on turns into a SessionClaim. Everything else the provider sent is ignored, which is the same position the records take: read what you use.

func Exchange

func Exchange(h Host, store *Store, cfg Config, flow Flow, code string) (IDToken, error)

Exchange trades an authorization code for an ID token and verifies it.

The exchange is `client_secret_post`, and that is the ABI's decision rather than this add-on's: the FetchRequest record carries no headers and the host sets exactly three, so there is no way to send an Authorization header and `client_secret_basic` is unreachable. A provider that offers only the basic form cannot be used, in the same way one that offers only form_post cannot.

func VerifyIDToken

func VerifyIDToken(token string, set JWKS, want Expectations) (IDToken, error)

VerifyIDToken checks the signature and then every claim OpenID Connect Core 3.1.3.7 requires of one, in that order.

Signature first, deliberately: a claim read out of a document nobody signed is not evidence of anything, and reading claims before verifying is how a verifier ends up branching on an attacker's `iss`.

func (IDToken) AsClaim

func (t IDToken) AsClaim() Claim

AsClaim is the SessionClaim this token becomes.

The issuer is the token's, which by the time this is called has been compared exactly against the configured one — so it is the same string either way, and taking it from the token is what makes that sentence checkable rather than assumed.

type JWK

type JWK struct {
	Kty string `json:"kty"`
	Kid string `json:"kid"`
	Use string `json:"use"`
	Alg string `json:"alg"`
	Crv string `json:"crv"`
	N   string `json:"n"`
	E   string `json:"e"`
	X   string `json:"x"`
	Y   string `json:"y"`
}

JWK is one key from a provider's key set, in the fields this add-on reads.

type JWKS

type JWKS struct {
	Keys []JWK `json:"keys"`
}

JWKS is a provider's key set.

type Minted

type Minted struct {
	ExpiresAt            string `json:"expires_at"`
	SecondFactorRequired bool   `json:"second_factor_required"`
}

Minted is the ABI's MintedSession record. SecondFactorRequired is the field a callback has to read before it decides where to send somebody: an account with TOTP enrolled meets the factor after this add-on's assertion rather than instead of it, and the host sends the visitor to its own prompt ahead of whatever location this add-on wrote.

type Provider

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

Provider reads the two documents an authorization-code flow needs, through the add-on's own schema so that the ordinary callback spends one fetch and not three.

A route handler is bounded by LINKCTRL_ADDON_ROUTE_DEADLINE — ten seconds by default and lower on some instances — and each fetch is bounded by LINKCTRL_ADDON_FETCH_TIMEOUT or by whatever is left of the route's, whichever ends first. The ABI says three fetches at the defaults fit and to budget as though the deadline were shorter; caching is how this add-on does that.

func NewProvider

func NewProvider(h Host, store *Store, cfg Config) *Provider

NewProvider does no I/O.

func (*Provider) Discovery

func (p *Provider) Discovery() (Discovery, error)

Discovery answers the metadata document, from the schema when it is fresh.

func (*Provider) ForgetKeys

func (p *Provider) ForgetKeys()

ForgetKeys drops the cached key set, which is what a rotation needs.

func (*Provider) Keys

func (p *Provider) Keys(d Discovery, refresh bool) (JWKS, error)

Keys answers the provider's key set, from the schema when it is fresh.

refresh forces a fetch, which is what a token naming a `kid` the cached set does not carry means. It is a parameter rather than a retry loop because the caller gets exactly one refresh per callback: a token that names an unknown key twice is a token this add-on is not going to verify, and a loop would be a way to spend a route deadline on somebody else's rotation schedule.

type Request

type Request struct {
	Method         string            `json:"method"`
	Path           string            `json:"path"`
	Query          string            `json:"query"`
	Cookies        map[string]string `json:"cookies"`
	ContentType    string            `json:"content_type"`
	AcceptLanguage string            `json:"accept_language"`
	Body           string            `json:"body"`
	BodyBase64     bool              `json:"body_base64"`
}

Request is the ABI's HTTPRequest record.

Path is relative to this add-on's own prefix and always begins with "/", so the callback arrives as "/callback" rather than as "/addons/oidc/callback". There is no header map and no client address in any spelling; the cookies are the ones whose names begin with a prefix the manifest declared.

type Response

type Response struct {
	Status      int      `json:"status,omitempty"`
	ContentType string   `json:"content_type,omitempty"`
	Location    string   `json:"location,omitempty"`
	SetCookie   []Cookie `json:"set_cookie,omitempty"`
	Body        string   `json:"body,omitempty"`
}

Response is the ABI's HTTPResponse record.

ContentType empty is the ordinary case and means the host wraps Body in the dashboard's own page, escaped. text/html is refused by the host, which is what makes "an add-on cannot inject markup" a property of the boundary; every page this add-on draws is therefore text.

func Assert

func Assert(h Host, cfg Config, mode string, token IDToken) (Response, error)

Assert hands a verified identity to the host, in whichever direction the flow was started for.

The two directions have opposite requirements about a session and that is the whole of what stops either doing the other's job: `identity_link` is ErrDenied when nobody is signed in, and `session_mint` is ErrDenied when somebody is. So this function does not check who is signed in before calling — the host's answer is the authority, and a check here would be a second opinion that could disagree with it.

func Begin

func Begin(h Host, store *Store, cfg Config, mode string) (Response, error)

Begin starts a flow and answers the redirect that sends the visitor away.

type Session

type Session struct {
	SignedIn       bool   `json:"signed_in"`
	UserID         string `json:"user_id"`
	Email          string `json:"email"`
	DisplayName    string `json:"display_name"`
	WorkspaceID    string `json:"workspace_id"`
	OrganizationID string `json:"organization_id"`
	Role           string `json:"role"`
}

Session is the ABI's SessionContext record: who is signed in on the request this add-on is answering, and never a cookie, a token or a session row.

type Store

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

Store is this add-on's Postgres schema, which the host owns and gives it.

Why there is storage at all

A module cannot keep state between two requests: its memory is new every time, because the host makes an instance per request. An authorization-code flow is two requests — the browser leaves for the provider and comes back — and what has to survive between them is the PKCE verifier, the `state` and the `nonce`. The ABI's own advice is the design here: keep a flow's state in the schema and a key to it in a cookie, because a cookie is about 3 KiB shared across everything this add-on ever sets and is under the visitor's hand besides.

Why the tables are created from the module

LinkCtrl runs an add-on's migrations for it — goose SQL in a `migrations/` directory, each file named in the manifest with its digest — and that is the better shape. It is not available to an add-on that wants to be installable: an install bundle is *exactly two files*, the manifest and the module, and the API's upload carries the same pair, so an add-on that ships DDL can only be put in place by hand in LINKCTRL_ADDONS_DIR. The first-party `pageviews` example makes the same trade and says so.

So the DDL is here, idempotent, and run at most once per invocation — after a probe, so that the ordinary request pays one round trip rather than three.

func NewStore

func NewStore(h Host) *Store

NewStore does no I/O. The schema is reached at the first statement.

func (*Store) ClaimFlow

func (s *Store) ClaimFlow(handle string) (Flow, error)

ClaimFlow consumes a flow exactly once and hands back what it held.

The claim is the INSERT and not the SELECT. Postgres decides which of two concurrent callbacks owns the handle, because only one of them can write the primary key; the loser is refused before it has read a verifier. A SELECT-then- DELETE would leave both of them holding the same PKCE verifier for as long as the two statements are apart.

A unique violation reaches this module as sdk.ErrInvalid with no detail: the ABI never lets a Postgres message cross, because one names tables and constraints. So *any* failure of this INSERT is read as "somebody else has it", which is the safe direction — the cost of being wrong is a sign-in the visitor retries, and the cost of guessing the other way is a replayable callback.

func (*Store) Document

func (s *Store) Document(name string) (string, error)

Document reads a cached provider document, or "" when there is none that is still fresh.

func (*Store) ForgetDocument

func (s *Store) ForgetDocument(name string) error

ForgetDocument drops one, which is what a key set that did not carry the `kid` an ID token named needs before it is fetched again.

func (*Store) SaveDocument

func (s *Store) SaveDocument(name, body string, ttlSeconds int) error

SaveDocument caches one, replacing whatever was there.

func (*Store) SaveFlow

func (s *Store) SaveFlow(f Flow, ttlSeconds int) error

SaveFlow writes a flow and gives it a lifetime.

func (*Store) Sweep

func (s *Store) Sweep() error

Sweep drops what has expired. Called from the route that starts a flow, which is the only one that has time to spare and the one whose rate is the rate rows are created at. Nothing caps how much an add-on stores and an operator watches linkctrl_addon_schema_bytes, so growing without bound is this add-on's to defend and it does not.

Jump to

Keyboard shortcuts

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