devidp

package
v0.1.45 Latest Latest
Warning

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

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Overview

Package devidp provisions a project's dev identity provider (Zitadel): registering the project and its single-page application, and publishing the values Zitadel GENERATES (client_id, project id) so the rest of the stack can reference them by name instead of by value.

It is a public package — unlike its internal/devidp sibling in the forge repo itself — because the code that calls it runs INSIDE a scaffolded project's own binary (the `auth idp-provision` subcommand, run as a deploy-time Job), which cannot import forge's internal packages across the module boundary.

Index

Constants

View Source
const BrokerTokenKey = "IDP_BROKER_TOKEN"

BrokerTokenKey is the name the login broker's token is stored under: the env var the scaffolded server reads it from (the `idp_broker_token` config field). One name for both halves, so the job that writes it and the server that reads it cannot disagree.

Variables

View Source
var ErrInvalidResetCode = errors.New("the reset code is not valid for this user")

ErrInvalidResetCode means the issuer rejected the reset code: wrong, expired, or already used. Deliberately one error for all three — a form that tells "expired" from "wrong" teaches a guesser when to stop.

View Source
var ErrUserNotFound = errors.New("no single user has that email address")

ErrUserNotFound is returned by FindUserByEmail when no human user has that address — or when more than one does, which is treated the same way: a reset keyed by an ambiguous email cannot know whose password it is changing.

Functions

func Issuer

func Issuer(browserOrigin string) string

Issuer is the `iss` the API server must enforce, and JWKSURL is where it fetches signing keys — both derived from the SAME browser-facing origin this project's dev IdP is registered under (forge's dev loop runs the backend as a host process reaching the IdP at that same address; see the `auth/dev-loop` skill for why an in-network address does not work here). Zitadel's issuer is the bare origin, no path suffix.

func JWKSURL

func JWKSURL(browserOrigin string) string

func LogoutGlob

func LogoutGlob(originGlob string) string

LogoutGlob builds the post-logout redirect PATTERN. It is the bare origin, not origin+basePath: oidc-provider.ts sends `window.location.origin` alone as post_logout_redirect_uri (unlike the callback URI, it does not append BASE_PATH), so the registered pattern must match that literally, not what RedirectGlob composes for the callback route.

func RedirectGlob

func RedirectGlob(originGlob, basePath, callbackPath string) string

RedirectGlob builds the dev-IdP redirect-URI PATTERN a scaffolded SPA registers, from the frontend's origin glob and its URL base path.

THE BUG THIS FIXES. The pattern used to be hardcoded as "http://localhost:*/auth/callback" — origin glob plus the bare callback path, with no base path segment. That matches a frontend served at the host root, but not one mounted under a prefix (forge.yaml frontends[].base_path, e.g. "/internal"): the browser's real callback is "http://localhost:3002/internal/auth/callback", and doublestar's `*` does not cross a `/` — so the registered pattern rejected every callback from a base-pathed frontend, failing at the LAST redirect of an otherwise perfect sign-in.

basePath is the frontend's forge.yaml base_path verbatim — "" for a frontend served at the host root, "/internal" for one mounted under a prefix. It is inserted between the origin and the callback path exactly where the browser puts it (window.location.origin + BASE_PATH + CALLBACK_PATH — see basepath_gen.ts / oidc-provider.ts), so the glob and the literal URL Zitadel actually receives agree on shape.

The `*` still spans ONLY the port: it is embedded in originGlob (the caller's "http://localhost:*"), never introduced here, so a base path containing a literal "*" is impossible — forge's own base_path validation (config.ValidateBasePath) restricts segments to [A-Za-z0-9._-], and a hand-edited config.k is the author's own responsibility, same as any other KCL value.

func RunningInCluster

func RunningInCluster() bool

RunningInCluster reports whether this process is running inside a Kubernetes pod, using the same signal controller-runtime's leader election uses to infer the lease namespace: the ServiceAccount namespace mount. It is what decides which Publisher a caller should use — a cluster Job writes a ConfigMap; a compose Job writes a committed KCL file — without either publisher needing to guess.

Types

type Application

type Application struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	// ClientID is what the browser sends. Zitadel GENERATES it, which is
	// the reason this package exists at all — everything else about the
	// dev IdP is declared in idp-steps.yaml.
	ClientID string `json:"clientId"`
}

Application is a registered OAuth client.

type BrokerCredential

type BrokerCredential struct {
	UserID string
	Token  string
}

BrokerCredential is the service account the login broker authenticates as, and the token it presents.

UserID is returned alongside the token because the role grant is keyed by user id, not by token — a caller that needs to re-grant (or audit) the role has the handle without another lookup.

type Client

type Client struct {
	// Base is the address this process DIALS. Inside a deploy-time Job
	// that is the in-network address (a compose service name or a
	// cluster-local Service DNS name), which is not necessarily the
	// BROWSER-facing origin — see Host.
	Base string

	// Host overrides the HTTP Host header, for when the address this
	// process dials differs from the address the instance is REGISTERED
	// under (Base above). Empty sends the Host implied by Base.
	//
	// Zitadel is multi-tenant and resolves which instance a request
	// belongs to from the Host header, matched against the instance's
	// ExternalDomain — not from the socket the request arrived on. A Job
	// running inside the compose network dials "idp:8080", but the dev
	// instance is registered under "localhost" (the browser's origin), so
	// every call needs this override or Zitadel answers "Instance not
	// found" with an otherwise perfectly valid, correctly authenticated
	// request.
	Host string

	// Token is the personal access token of the service account declared
	// in idp-steps.yaml, which Zitadel writes to disk on first boot.
	Token string

	// OrgID scopes management calls to one organization. Resolved by
	// DefaultOrg when empty.
	OrgID string
	// contains filtered or unexported fields
}

Client talks to a running Zitadel instance.

ONE base URL, unlike a provider that splits its admin API onto a second port: Zitadel serves the OIDC endpoints, the resource APIs (`/v2/...`) and the management API (`/management/v1/...`) on a single port, and authorizes all of them with the same bearer token.

func (*Client) BeginFlow

func (c *Client) BeginFlow(ctx context.Context, cfg FlowConfig) (Flow, error)

BeginFlow creates an authorization request at the issuer and returns the handle needed to complete it.

This calls /authorize the way a browser would, but WITHOUT following the redirect: the response's Location header carries the auth request id, which is the only part that matters. Nothing navigates.

func (*Client) CreateSession

func (c *Client) CreateSession(ctx context.Context, creds Credentials) (Session, error)

CreateSession authenticates a user and returns the resulting session.

The credential check happens INSIDE the issuer: this call ships the password to Zitadel, which verifies it against its own stored hash. This process never sees, stores or compares a password hash, which is the property that makes brokering the call acceptable in the first place.

A bad password returns an error carrying Zitadel's own message (COMMAND-3M0fs, with a failedAttempts count) rather than a paraphrase: lockout policy is the issuer's to enforce and to report.

func (*Client) DefaultOrg

func (c *Client) DefaultOrg(ctx context.Context) (Org, error)

DefaultOrg returns the instance's first organization, which for a forge-scaffolded instance is the one idp-steps.yaml declared.

It is looked up rather than pinned because the ID is generated: the NAME is declarative (it is in idp-steps.yaml), the snowflake ID it gets is not.

func (*Client) EnsureLoginBroker

func (c *Client) EnsureLoginBroker(ctx context.Context, username string) (BrokerCredential, error)

EnsureLoginBroker provisions the DEDICATED service account the login broker runs as, grants it the login-client role, and returns a token for it.

WHY A SEPARATE ACCOUNT rather than reusing the provisioning credential. The provisioner's token is an IAM_OWNER credential that exists to register applications at deploy time; the broker's token exists to complete logins at request time, lives in a long-running server, and is reachable from the request path. Those are different lifetimes and very different blast radii, and folding them into one token means a compromise of the busiest surface in the app yields the credential that administers the whole instance.

Splitting them costs one more account in the dev bootstrap and buys the ability to rotate or revoke the login path without touching provisioning.

NOT IDEMPOTENT IN THE USUAL SENSE, and deliberately: Zitadel issues a personal access token exactly once and never returns it again, so a re-run against an existing account cannot recover the previous token and mints a NEW one instead. The old token stays valid until it expires or is removed — so callers should provision once and persist the result, rather than calling this on every boot. ProvisionLoginBroker is that caller: it keeps a stored token the issuer still accepts and mints only when it must.

func (*Client) EnsureLoginClientRole

func (c *Client) EnsureLoginClientRole(ctx context.Context, userID string) error

EnsureLoginClientRole grants the service account behind this client the role the API-only sign-in flow requires, preserving whatever roles it already holds.

THIS IS A PRIVILEGED GRANT. IAM_LOGIN_CLIENT permits completing an authorization request on behalf of any user in the instance, so the credential holding it is as sensitive as the sign-in flow itself. It belongs to the one backend that brokers logins — never in a frontend bundle, a build environment, or a shared operator token.

userID is the service account's own user id. Roles are reconciled as a SET: the existing roles are read and this one added, so a re-run is a no-op and an unrelated role is never dropped.

func (*Client) EnsureProject

func (c *Client) EnsureProject(ctx context.Context, name string) (Project, error)

EnsureProject creates the project, or returns the existing one with the same name.

func (*Client) EnsureSPAApplication

func (c *Client) EnsureSPAApplication(ctx context.Context, projectID, name string, redirectURIs, postLogoutURIs []string) (Application, error)

EnsureSPAApplication creates the browser client, or adopts the existing one with the same name (refreshing its redirect URIs so a changed declaration does not silently break the callback).

The URIs may be GLOB PATTERNS (see RedirectGlob) — this app is always in devMode, where Zitadel matches redirect URIs as globs rather than by equality. That is what keeps a dev frontend's port kernel-assigned instead of pinned; see spaOIDCConfig for the mechanism and for why widening an allow-list is defensible only for this dev IdP.

func (*Client) FinalizeAuthRequest

func (c *Client) FinalizeAuthRequest(ctx context.Context, authRequestID string, session Session) (string, error)

FinalizeAuthRequest completes an OIDC authorization request with an authenticated session, and returns the callback URL the browser should be sent to. That URL carries the authorization code and the original `state`.

authRequestID is the value the issuer put in the query string when it redirected into this app (see SetLoginUI). Pass it through unchanged, INCLUDING any prefix: Zitadel's v2 ids are prefixed (`V2_...`), and the prefix is part of the id.

A v1 auth request cannot be finalized this way — it fails "Auth Request does not exist (COMMAND-jae5P)", which reads like a expiry bug but means the LoginV2 feature is not enabled for this instance. SetLoginUI is what makes the issuer mint v2 requests in the first place, so the two are a package: neither is useful alone.

func (*Client) FindUserByEmail

func (c *Client) FindUserByEmail(ctx context.Context, email string) (User, error)

FindUserByEmail resolves an email address to the one human user that has it, case-insensitively.

The broker's login-client credential may search users, so this works with the same token SignIn uses.

func (*Client) ProvisionLoginBroker

func (c *Client) ProvisionLoginBroker(ctx context.Context, username, stored string) (cred BrokerCredential, minted bool, err error)

ProvisionLoginBroker is EnsureLoginBroker made convergent: it keeps `stored` when the issuer still accepts it as the broker account's token, and mints a new one only when there is none or the issuer has stopped accepting it (a reset IdP, a revoked token).

WHY. Zitadel shows a personal access token once, so a step that mints on every run cannot be idempotent — and the idp-provision job runs on every `forge env up`. Minting each time left a live credential behind per run and rotated the token under a server that was already holding the old one. Persisting the token and reusing it is what makes this step converge like the rest of the job.

minted reports whether the returned token is new, i.e. whether the caller has to store it. The account and its login-client role are converged either way, so a reused token is never one that lost its role.

func (*Client) RedeemCode

func (c *Client) RedeemCode(ctx context.Context, cfg FlowConfig, code string, pkce PKCE) (Tokens, error)

RedeemCode exchanges an authorization code for tokens.

The PKCE verifier proves this is the same party that started the flow. It has been in this process's memory the entire time, which is what makes the proof meaningful — in a browser flow it has to survive a redirect in storage a script can read.

No client secret: this is a public client using PKCE. Adding one would not improve anything here, and would put a credential in config that PKCE already replaced.

func (*Client) Register

func (c *Client) Register(ctx context.Context, reg Registration) (string, error)

Register creates a new human user at the issuer and returns its id.

WHY THIS IS A SERVER CALL. Creating a user needs `user.write`, which a browser cannot hold — the same reason the session and callback calls are brokered. The credential is the login broker's, so registration and sign-in share one account and one blast radius rather than adding a second privileged token.

The issuer enforces its own password policy and uniqueness; a rejection comes back as its own message rather than a paraphrase, because "must contain a symbol" is exactly what the person filling in the form needs to read.

func (*Client) ResetPassword

func (c *Client) ResetPassword(ctx context.Context, r PasswordReset) error

ResetPassword sets a user's password using a one-time reset code.

A missing field is refused BEFORE any network call — above all a missing Code. See the file header: with the broker's credential, the issuer would accept a code-less request and overwrite the password of whichever user is named. There is no safe default for "which code did the person present".

Errors a caller should branch on:

  • ErrInvalidResetCode (wrapped): wrong, expired or used code;
  • *PasswordPolicyError: code fine, password rejected — the code is still usable.

Anything else is the issuer or the credential failing, and is returned with the issuer's own message.

func (*Client) SetLoginUI

func (c *Client) SetLoginUI(ctx context.Context, loginURI string) error

SetLoginUI points the instance's login UI at loginURI — the app's OWN sign-in route — so /oauth/v2/authorize redirects there instead of rendering the issuer's bundled pages.

This is the switch that makes the whole flow possible, and it is why a project that merely calls CreateSession still ends up on the issuer's login screen. Empty loginURI restores the issuer-hosted UI.

Enabling it also changes the SHAPE of the auth request the issuer mints (v2 rather than v1), which is what FinalizeAuthRequest requires. That coupling is the reason both live in this file.

func (*Client) SignIn

func (c *Client) SignIn(ctx context.Context, cfg FlowConfig, creds Credentials) (Tokens, error)

SignIn runs the ENTIRE flow — begin, authenticate, finalize, redeem — and returns the tokens. One call, no browser involvement at all.

This is the function an app's POST /auth/login handler wraps. Everything it needs from the browser is a login name and a password; everything the browser gets back is whatever session representation the app chooses.

The login name may be an EMAIL, which is what a sign-in form asks for. It is resolved to the account's real login name first (see loginNameFor): the issuer creates sessions by login name, and the two differ for any account created with a bare username.

func (*Client) SignUp

func (c *Client) SignUp(ctx context.Context, cfg FlowConfig, reg Registration) (Tokens, error)

SignUp registers an account and signs it in, in one call.

The sign-in is not a convenience: an account created without a session leaves the person who just chose a password on a login form retyping it, which is the worst step of most sign-up flows and one this shape removes for free.

func (*Client) WaitReady

func (c *Client) WaitReady(ctx context.Context) error

WaitReady blocks until the IdP answers its readiness probe, or ctx expires.

/debug/ready is Zitadel's own unauthenticated readiness route, and it reports READY rather than merely listening — which matters here because the first boot runs the declarative setup (schema, instance, org, admin user, PAT) before serving, and a probe that only checked for an open socket would hand back a client that 404s every call.

type ConfigMapPublisher

type ConfigMapPublisher struct {
	// Name is the ConfigMap this job publishes into. Chosen by the
	// project's own KCL (the same name a consumer's configMapKeyRef
	// names), never generated.
	Name string
	// contains filtered or unexported fields
}

ConfigMapPublisher writes Identity into a named ConfigMap in the pod's own namespace, using the ServiceAccount token Kubernetes projects into every pod. It needs no external client library: the Kubernetes API is plain REST-over-TLS, and this job's whole interaction with it is one idempotent PATCH — the same "narrow REST client" shape as the Zitadel Client above, not a general-purpose Kubernetes client.

A workload never reads the ConfigMap through this publisher — it references the ConfigMap BY NAME via configMapKeyRef, which is the entire point of the convergence design: the render never needs to learn the generated value, because the declaration names where to find it rather than what it is.

func (*ConfigMapPublisher) Publish

func (p *ConfigMapPublisher) Publish(ctx context.Context, id Identity) error

Publish creates the ConfigMap if it does not exist, or PATCHes its `data` if it does. Idempotent: a second run against an already-current ConfigMap is a no-op PATCH, which is what "converges on every deploy" requires.

type Credentials

type Credentials struct {
	// LoginName identifies the account: the username, email or other
	// login name the issuer knows. Required.
	LoginName string

	// Password is the user's password. Required unless another verifying
	// factor is set — see buildChecks.
	Password string

	// TOTP is a time-based one-time code, when the account has TOTP
	// enrolled. Supplied ALONGSIDE Password: Zitadel records each factor
	// separately on the session, so a second factor adds to the password
	// check rather than replacing it.
	TOTP string
}

The API-only sign-in flow: how an app authenticates a user WITHOUT ever sending the browser to the identity provider's own login pages.

── Why this exists ───────────────────────────────────────────────────

The default OIDC authorization-code flow redirects the user agent to the issuer's /authorize endpoint, which answers with the IdP's OWN login UI. The user types their password on the issuer's origin, under the issuer's branding, and comes back with a code. That works, and for many products it is the right choice — an IdP-hosted page is one less place for a credential bug to live.

It is the wrong choice when the sign-in screen is part of the PRODUCT. Leaving the app to authenticate is a visible seam: different typography, a different URL in the address bar, no way to put a product's own empty states or error copy on the most-seen screen it has. This package's job is to make the other option available without asking anyone to reverse-engineer it.

── The three calls, and the one that is easy to get wrong ────────────

Zitadel's v2 API exposes the whole flow, so an app can drive it:

  1. The browser hits /oauth/v2/authorize as usual. With the LoginV2 feature pointed at the app (SetLoginUI below), the issuer redirects BACK INTO THE APP with an `authRequest` id instead of rendering its own page.
  2. The app collects credentials on its own screen and calls CreateSession — the credential check happens here.
  3. The app calls FinalizeAuthRequest with the resulting session, and receives the callback URL carrying the authorization code. From there the browser completes a standard PKCE token exchange; the backend never handles the user's tokens.

── THE BYPASS THIS FILE EXISTS TO PREVENT ────────────────────────────

Zitadel treats "which factors were checked" as the CALLER's decision. A session created with a user check and NO password check is a perfectly valid session, and it will finalize an auth request and yield real tokens. Verified against v4.16.2: a session with `checks: {user: {...}}` and nothing else produced a working access_token and id_token for an admin account, and the id_token carried NO `amr` claim — so a downstream validator cannot even tell the credential was never verified.

A wrong password is correctly rejected (COMMAND-3M0fs). Omitting the password entirely is not rejected at all. The difference matters because it is the difference between a login form and an impersonation endpoint.

So this package does NOT expose a "build your own checks" surface, and deliberately accepts no caller-supplied JSON for the checks object. Credentials is a closed set of typed fields, each mapping to exactly one factor, and buildChecks REFUSES to produce a checks object with no verifying factor in it. A handler that forwards a request body straight through cannot silently create a passwordless session, because there is no code path that accepts one.

If you need a factor this does not model, add a field to Credentials and a case to buildChecks. Do not reach around them.

type Flow

type Flow struct {
	AuthRequestID string
	PKCE          PKCE
}

Flow is one in-progress login: the issuer's auth request id, and the PKCE pair that will redeem it.

Hold this for the duration of ONE login attempt. It is not a session and must not be given to a browser: whoever holds the verifier can redeem the code.

type FlowConfig

type FlowConfig struct {
	ClientID    string
	RedirectURI string
	// Scopes requested. Empty defaults to the standard OIDC set.
	Scopes []string
}

FlowConfig is what an app needs to drive the flow: the OAuth client it registered, and where the issuer should send the code.

RedirectURI is still required even though no browser is redirected — the issuer validates it against the registered allowlist, and the token exchange must present the identical value. It is a protocol parameter here, not a navigation target.

type Identity

type Identity struct {
	// ClientID is the SPA application's OAuth client id.
	ClientID string
	// Audience is the Zitadel project id — the token's `aud`, and the
	// value the backend must enforce as its own audience.
	Audience string
	// Issuer and JWKSURL are derived from the browser-facing origin, not
	// generated, but travel with the rest of the identity because every
	// consumer needs all four together.
	Issuer  string
	JWKSURL string
}

Identity is the convergence output: the values Zitadel GENERATED when the project and its browser application were registered, and that nothing else can declare up front (see the package doc comment).

func Reconcile

func Reconcile(ctx context.Context, client *Client, spec Spec, browserOrigin string) (Identity, error)

Reconcile converges one application against client and returns the Identity the run produced: the client id and audience Zitadel generated, paired with the issuer/JWKS URL browserOrigin implies.

It ADOPTS an existing project/application rather than creating a second one (EnsureProject / EnsureSPAApplication both read before they write), which is what makes this idempotent — the property every consumer of this function's caller (a deploy-time Job, run on every deploy) depends on.

func (Identity) AsMap

func (id Identity) AsMap() map[string]string

AsMap projects Identity to the flat string map both publishers write — cluster ConfigMap data and the compose/dev KCL literal share these same four keys, so a consumer's declaration (a configMapKeyRef, or a `.` lookup into the committed KCL) names the identical key regardless of which target rendered it.

type KCLFilePublisher

type KCLFilePublisher struct {
	// Path is the KCL file this job owns, e.g.
	// deploy/kcl/dev/idp_identity_gen.k.
	Path string
}

KCLFilePublisher writes Identity as a committed KCL literal — the compose/dev equivalent of a ConfigMap, for a target with no ConfigMap concept at all. The env's config.k imports this file directly (`import .idp_identity_gen as idp`), so a render never needs to learn the generated value: it is already a fact in a file the import graph already reaches, the same way control-plane's identity_internal_console_gen.k works.

COMMITTED, not gitignored — a fresh clone must render a complete configuration offline, before its own IdP container has ever booted. Re-running with the SAME values leaves the file byte-identical (see Publish), so a converged dev stack produces no spurious diff.

func (*KCLFilePublisher) Publish

func (p *KCLFilePublisher) Publish(_ context.Context, id Identity) error

Publish writes id as a wholly-owned generated KCL module: one dict, `idp_identity`, with the same four keys ConfigMapPublisher writes under AsMap — one shared vocabulary across both targets.

type Org

type Org struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

Org is a Zitadel organization — the container a project and its users live in.

type PKCE

type PKCE struct {
	Verifier  string
	Challenge string
}

PKCE is one flow's proof-of-possession pair.

The verifier NEVER leaves the server. That is the point of doing this here: in the browser flow the verifier has to survive a redirect, which means sessionStorage, which means it is readable. Here it is a value in a struct that lives as long as one login attempt.

func NewPKCE

func NewPKCE() (PKCE, error)

NewPKCE generates a verifier and its S256 challenge (RFC 7636).

type PasswordPolicyError

type PasswordPolicyError struct {
	// Reason is the issuer's own description ("Password is too short"),
	// safe and useful to show the person who typed it.
	Reason string
}

PasswordPolicyError means the code was ACCEPTED but the new password failed the issuer's password policy. The code is still valid afterwards (verified), so the person should pick a different password, not ask for a new code — which is why a caller must be able to tell this apart from ErrInvalidResetCode.

func (*PasswordPolicyError) Error

func (e *PasswordPolicyError) Error() string

type PasswordReset

type PasswordReset struct {
	UserID      string
	Code        string
	NewPassword string
}

PasswordReset is one redemption of a reset code. Every field is required.

type Project

type Project struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

Project groups applications inside an organization. In Zitadel, a token's `aud` is the project id, which is why EnsureProject's return value is also the AUDIENCE the backend must enforce.

type Publisher

type Publisher interface {
	Publish(ctx context.Context, id Identity) error
}

Publisher converges Identity into wherever this environment's workloads read it from. See ConfigMapPublisher (cluster) and KCLFilePublisher (compose/dev) — exactly one of the two applies to any given run, decided by RunningInCluster.

type Registration

type Registration struct {
	Email      string
	Password   string
	GivenName  string
	FamilyName string
	// EmailVerified marks the address trusted without a mail round trip.
	EmailVerified bool
}

Registration is a new account's details, as a sign-up form collects them.

EmailVerified is deliberately a caller decision rather than a fixed value. A dev stack has no mail server, so an unverified address strands the account behind a verification step nothing can deliver — the exact dead end that makes a scaffolded sign-up feel broken. A production deployment with real mail should set it false and let the issuer send the verification.

type SecretFile

type SecretFile struct {
	Path string
}

SecretFile is a FileSecrets store — the gitignored YAML file a local environment declares as `secret_provider = forge.FileSecrets {path = ...}`: a flat map of env-var NAME to value, which forge layers onto every host process that declares the name in its `config_secrets`.

It is how the idp-provision job PUBLISHES the one value it produces that is a credential. The identity it converges is public and goes to a committed KCL file (KCLFilePublisher); the broker token is not, so it goes where every other dev secret already lives, and reaches the server the same way they do.

func (SecretFile) Get

func (s SecretFile) Get(key string) (string, error)

Get returns the value stored under key, "" when the file or the key is absent (a fresh clone has no store yet; that is not an error).

func (SecretFile) Set

func (s SecretFile) Set(key, value string) error

Set stores value under key, creating the file (0600) when absent.

Every other key, and every comment, is kept: the scaffolded store documents each slot it holds, and a developer's own values sit beside this one. A rewrite that dropped either to set one key would be a worse bug than the one it fixed.

type Session

type Session struct {
	ID    string `json:"sessionId"`
	Token string `json:"sessionToken"`
}

Session is an authenticated session at the issuer: the handle that FinalizeAuthRequest exchanges for an authorization code.

The token is a bearer credential for this session. It is returned so a caller can hold it across the two calls, and for no other reason — it must never be sent to a browser.

type Spec

type Spec struct {
	// Project is the Zitadel project name. Its generated id becomes the
	// token audience.
	Project string
	// Frontend is the SPA application name, registered inside Project.
	Frontend string
	// RedirectURIs / PostLogoutURIs are the patterns (or literal URIs, for
	// a non-dev issuer) registered with the issuer. Build these with
	// RedirectGlob for the scaffolded dev IdP.
	RedirectURIs   []string
	PostLogoutURIs []string

	// LoginURI is the app's OWN sign-in route. Set it to run the API-only
	// sign-in flow: the issuer then redirects /oauth/v2/authorize INTO the
	// app instead of rendering its own login pages, and the app drives the
	// flow through CreateSession / FinalizeAuthRequest (see login.go).
	//
	// EMPTY (the default) keeps the issuer-hosted login UI, which is the
	// standard OIDC redirect flow and the right choice for a project that
	// does not want to own a credential form.
	//
	// Setting this is what makes the issuer mint v2 auth requests, which
	// FinalizeAuthRequest requires — so it is not cosmetic. Reconcile
	// converges it in BOTH directions: a project that stops setting it
	// goes back to the issuer-hosted UI rather than silently keeping the
	// last value someone configured.
	LoginURI string
}

Spec is what one dev-IdP convergence run declares: the project and application names to register, and the redirect URI PATTERN (RedirectGlob) the SPA should be reachable at.

type Tokens

type Tokens struct {
	AccessToken  string `json:"access_token"`
	IDToken      string `json:"id_token"`
	RefreshToken string `json:"refresh_token"`
	TokenType    string `json:"token_type"`
	ExpiresIn    int    `json:"expires_in"`
}

Tokens is what a completed flow yields.

These are the credentials themselves. They belong in a server-side session, or in an HttpOnly cookie — never in a JSON response a script can read, which would give away the property this whole file exists to establish.

type User

type User struct {
	ID string
	// LoginName is what CreateSession identifies the account by, and it is
	// NOT necessarily the email. An account created with a bare username
	// gets a login name of `<username>@<org domain>` — the seeded admin of
	// a forge dev IdP is `admin@<org>.localhost` while its email is the
	// address people actually type. Resolve through FindUserByEmail
	// rather than passing a typed email to CreateSession.
	LoginName string
	Email     string
}

User is the subset of an issuer account a sign-in or reset flow needs.

Jump to

Keyboard shortcuts

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