Documentation
¶
Overview ¶
Package devidp is the sign-in broker's client for a Zitadel instance: it drives the OIDC authorization-code flow with PKCE on the server's behalf (BeginFlow, SignIn, SignUp, RedeemCode), creates and finalizes sessions with the login broker's token, and resets passwords. The token may come from a file that is re-read on each use, so a rotated credential needs no restart.
It does NOT converge an instance. Registering the project, applications, roles, users and the broker account is pkg/identity's job: `forge env up` runs it against the dev IdP and the control plane runs it against hosted instances.
Index ¶
- Constants
- Variables
- func CheckClientData(credential json.RawMessage, origin, ceremony string) error
- func ClientIP(r *http.Request, trustedHops int) string
- func ClientKey(r *http.Request, trustedHops int) string
- func NewCrossOriginProtection(appBase string) *http.CrossOriginProtection
- func SafeReturnTo(p string) string
- type Answer
- type BrokerCredential
- type Challenge
- type ChallengeSession
- type Client
- func (c *Client) AddIDPLink(ctx context.Context, userID string, link IDPLink) error
- func (c *Client) BeginFlow(ctx context.Context, cfg FlowConfig) (Flow, error)
- func (c *Client) CompleteSignIn(ctx context.Context, cfg FlowConfig, s Session) (Tokens, error)
- func (c *Client) CreateChallengeSession(ctx context.Context, loginName, userID string, ch Challenge) (ChallengeSession, error)
- func (c *Client) CreateSession(ctx context.Context, creds Credentials) (Session, error)
- func (c *Client) CreateUser(ctx context.Context, u NewUser) (string, error)
- func (c *Client) DefaultOrg(ctx context.Context) (Org, error)
- func (c *Client) DeleteMetadata(ctx context.Context, userID, key string) error
- func (c *Client) DeleteSession(ctx context.Context, s Session) error
- func (c *Client) EndSessions(ctx context.Context, userID string) (int, error)
- func (c *Client) EnsureLoginBroker(ctx context.Context, username string) (BrokerCredential, error)
- func (c *Client) EnsureLoginClientRole(ctx context.Context, userID string) error
- func (c *Client) EnsureOTPEmail(ctx context.Context, userID string) error
- func (c *Client) FinalizeAuthRequest(ctx context.Context, authRequestID string, session Session) (string, error)
- func (c *Client) FindUserByEmail(ctx context.Context, email string) (User, error)
- func (c *Client) FindUserByID(ctx context.Context, id string) (User, error)
- func (c *Client) GetSessionFactors(ctx context.Context, s Session) (SessionFactors, error)
- func (c *Client) GrantProjectRole(ctx context.Context, userID, projectID, role string) error
- func (c *Client) HasBrokerToken() bool
- func (c *Client) HasMetadata(ctx context.Context, userID, key string) (bool, error)
- func (c *Client) HasPassword(ctx context.Context, userID string) (bool, error)
- func (c *Client) ListIDPLinks(ctx context.Context, userID string) ([]IDPLink, error)
- func (c *Client) ListPasskeys(ctx context.Context, userID string) ([]Passkey, error)
- func (c *Client) LockUser(ctx context.Context, userID string) error
- func (c *Client) Metadata(ctx context.Context, userID, key string) (string, bool, error)
- func (c *Client) MintPasskeyRegistrationCode(ctx context.Context, userID string) (PasskeyRegistrationCode, error)
- func (c *Client) MintPasswordResetCode(ctx context.Context, userID string) (string, error)
- func (c *Client) PasswordPolicy(ctx context.Context) (PasswordPolicy, error)
- func (c *Client) RedeemCode(ctx context.Context, cfg FlowConfig, code string, pkce PKCE) (Tokens, error)
- func (c *Client) Register(ctx context.Context, reg Registration) (string, error)
- func (c *Client) RegisterPasskey(ctx context.Context, userID, domain string, code *PasskeyRegistrationCode) (PasskeyRegistration, error)
- func (c *Client) ResendEmailCode(ctx context.Context, userID, urlTemplate string) error
- func (c *Client) ResetPassword(ctx context.Context, r PasswordReset) error
- func (c *Client) RetrieveIDPIntent(ctx context.Context, intentID, token string) (IDPResult, error)
- func (c *Client) RevokeCredentials(ctx context.Context, userID string) (int, error)
- func (c *Client) SendPasswordResetLink(ctx context.Context, userID, urlTemplate string) error
- func (c *Client) SetLoginUI(ctx context.Context, loginURI string) error
- func (c *Client) SetMetadata(ctx context.Context, userID, key, value string) error
- func (c *Client) SetPassword(ctx context.Context, userID, password string) error
- func (c *Client) SetSession(ctx context.Context, cs ChallengeSession, a Answer) (Session, error)
- func (c *Client) SignIn(ctx context.Context, cfg FlowConfig, creds Credentials) (Tokens, error)
- func (c *Client) SignUp(ctx context.Context, cfg FlowConfig, reg Registration) (Tokens, error)
- func (c *Client) StartIDPIntent(ctx context.Context, idpID, successURL, failureURL string) (authURL, intentID string, err error)
- func (c *Client) UnlockUser(ctx context.Context, userID string) error
- func (c *Client) UserInfo(ctx context.Context, accessToken string) (UserInfo, error)
- func (c *Client) VerifyEmail(ctx context.Context, userID, code string) error
- func (c *Client) VerifyInviteCode(ctx context.Context, userID, code string) error
- func (c *Client) VerifyPasskeyRegistration(ctx context.Context, userID, passkeyID string, credential json.RawMessage, ...) error
- func (c *Client) WaitReady(ctx context.Context) error
- type Credentials
- type Flow
- type FlowConfig
- type GuardedMux
- type IDPIntent
- type IDPLink
- type IDPResult
- type LoginPolicy
- func (p LoginPolicy) MailDelivers() bool
- func (p LoginPolicy) Offers(method string) bool
- func (p LoginPolicy) PasswordPolicy() (PasswordPolicy, bool)
- func (p LoginPolicy) RegisterServed() bool
- func (p LoginPolicy) SelfSignup() bool
- func (p LoginPolicy) SignupAllows(email string) bool
- func (p LoginPolicy) SocialByIdPID(id string) (LoginPolicySocial, bool)
- func (p LoginPolicy) SocialByType(typ string) (LoginPolicySocial, bool)
- type LoginPolicyPassword
- type LoginPolicySocial
- type NewUser
- type Options
- type OptionsSocial
- type Org
- type PKCE
- type Passkey
- type PasskeyRegistration
- type PasskeyRegistrationCode
- type PasswordPolicy
- type PasswordPolicyError
- type PasswordReset
- type PasswordRule
- type Registration
- type Session
- type SessionFactors
- type SignInRoutes
- func (r *SignInRoutes) HandleInviteAccept(w http.ResponseWriter, req *http.Request)
- func (r *SignInRoutes) HandleMagicConsume(w http.ResponseWriter, req *http.Request)
- func (r *SignInRoutes) HandleMagicRequest(w http.ResponseWriter, req *http.Request)
- func (r *SignInRoutes) HandlePasskeyBegin(w http.ResponseWriter, req *http.Request)
- func (r *SignInRoutes) HandlePasskeyFinish(w http.ResponseWriter, req *http.Request)
- func (r *SignInRoutes) HandlePasskeyRegisterBegin(w http.ResponseWriter, req *http.Request)
- func (r *SignInRoutes) HandlePasskeyRegisterFinish(w http.ResponseWriter, req *http.Request)
- func (r *SignInRoutes) HandlePasskeys(w http.ResponseWriter, req *http.Request)
- func (r *SignInRoutes) HandleRegister(w http.ResponseWriter, req *http.Request)
- func (r *SignInRoutes) HandleSocialCallback(w http.ResponseWriter, req *http.Request)
- func (r *SignInRoutes) HandleSocialStart(w http.ResponseWriter, req *http.Request)
- func (r *SignInRoutes) HandleVerify(w http.ResponseWriter, req *http.Request)
- func (r *SignInRoutes) HandleVerifyResend(w http.ResponseWriter, req *http.Request)
- func (r *SignInRoutes) RPID() string
- type SignInRoutesConfig
- type Tokens
- type User
- type UserInfo
Constants ¶
const ( SignupInvite = "invite" SignupOpen = "open" SignupDomain = "domain" )
Signup modes.
const ( MethodPassword = "password" MethodPasskey = "passkey" MethodMagicLink = "magic_link" )
First-factor methods.
const ( MailPlatform = "platform" MailSMTP = "smtp" MailDev = "dev" MailSandbox = "sandbox" MailPaused = "paused" MailNone = "none" )
Mail delivery states the document reports.
const ( WebAuthnGet = "webauthn.get" WebAuthnCreate = "webauthn.create" )
WebAuthn ceremony types (clientDataJSON.type).
const ( // MetaSelfSignup marks an account created by public sign-up (register, // a magic link, a social sign-in), as opposed to one an owner invited // or the identity declares. Only such an account gets signup_role. MetaSelfSignup = "forge.self_signup" // MetaSignupRoleGranted records that signup_role was granted, so a // re-verification (an address change) does not grant it again after an // owner revoked it. MetaSignupRoleGranted = "forge.signup_role_granted" // MetaProofLock names the proof that holds an account locked across // its address proof: a random nonce, written before the lock. Only // the proof whose nonce is still current may lift a lock after a // failed proof; a lock with no marker (the issuer's, or one a failed // revocation left) is never lifted by a proof. See proveAddress. MetaProofLock = "forge.proof_lock" )
Account metadata keys this package writes.
const ( PasskeyBeginPath = "POST /auth/passkey/begin" PasskeyFinishPath = "POST /auth/passkey/finish" PasskeyRegisterBeginPath = "POST /auth/passkey/register/begin" PasskeyRegisterFinishPath = "POST /auth/passkey/register/finish" PasskeysPath = "GET /auth/passkeys" MagicRequestPath = "POST /auth/magic/request" MagicConsumePath = "POST /auth/magic/consume" SocialStartPath = "POST /auth/social/start" SocialCallbackPath = "POST /auth/social/callback" InviteAcceptPath = "POST /auth/invite/accept" VerifyPath = "POST /auth/verify" VerifyResendPath = "POST /auth/verify/resend" )
The routes. The scaffolded broker mounts each with mux.Handle(<constant>, http.HandlerFunc(routes.Handle<Name>)); the scaffolded frontend is written against these exact paths.
const ( CodeEmailUnverified = "email_unverified" CodePasskeyFailed = "passkey_failed" CodeNotSignedIn = "not_signed_in" CodeInvalidCode = "invalid_code" CodeMagicOtherBrowser = "magic_other_browser" CodeSocialFailed = "social_failed" CodeSocialLinkRequired = "social_link_required" CodeSocialNoAccount = "social_no_account" CodeSignupNotAllowed = "signup_not_allowed" CodePasswordRequired = "password_required" CodeMethodNotOffered = "method_not_offered" CodeUnknownProvider = "unknown_provider" CodeRateLimited = "rate_limited" CodeAccountExists = "account_exists" CodeSocialTaken = "social_taken" CodeCrossSite = "cross_site" CodePasswordTooLong = "password_too_long" )
Error codes in a refusal's `code` field, which the sign-in screen branches on.
const DefaultResponseFloor = 1200 * time.Millisecond
DefaultResponseFloor is how long HandleMagicRequest and HandlePasskeyBegin take at least. Their work differs by account state — an unknown address costs one lookup, a known one a lookup, a method check and a challenge — and without a floor the difference in response time says which addresses have accounts. It is set above what the slower path takes against a hosted instance; work that overruns it still leaks, so raise it if yours does.
const InviteMethodSocial = "social"
InviteMethodSocial is the method an invitee names to accept an invitation by connecting one of the policy's social accounts. It is not a first-factor method of the document: the social buttons are its Social list.
const LoginPolicyVersion = 1
LoginPolicyVersion is the only LOGIN_POLICY version this package reads.
const UserVerificationRequired = "USER_VERIFICATION_REQUIREMENT_REQUIRED"
UserVerificationRequired is the only user-verification requirement this package requests for a passkey: the authenticator must check its owner (PIN, biometric), so a passkey is a whole factor, not just possession.
Variables ¶
var ErrEmailUnverified = errors.New("the account's email address is not verified")
ErrEmailUnverified means the credentials were right but the account's address is not verified, and the policy issues no session for one. The sign-in screen offers to resend the verification mail.
var ErrInvalidCode = errors.New("the code is not valid")
ErrInvalidCode means the issuer rejected a one-time code: wrong, expired or already used. One error for all three, as for ErrInvalidResetCode.
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.
var ErrLoginPolicyUnreadable = errors.New("the LOGIN_POLICY document could not be used; serving the legacy policy (password only, invite-only)")
ErrLoginPolicyUnreadable wraps every reason a present document was not used.
var ErrNoVerifiedFactor = errors.New("refusing to finalize a session with no verified factor")
ErrNoVerifiedFactor means a session reached CompleteSignIn without any verifying factor recorded on it: the bypass, refused.
ErrPasswordPolicyUnavailable reports that the issuer does not serve its password-complexity policy at all (404, 405 or 501 on the Settings API), as opposed to failing to serve it right now. Test with errors.Is; the wrapped error still carries the issuer's answer.
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 CheckClientData ¶ added in v1.2.0
func CheckClientData(credential json.RawMessage, origin, ceremony string) error
CheckClientData verifies the clientDataJSON inside a browser credential: its origin must equal origin exactly (scheme, host and port), its type must be ceremony, and it must not be a cross-origin (iframe) ceremony. credential is the PublicKeyCredential JSON with base64url fields.
func ClientIP ¶ added in v1.2.0
ClientIP is the address to rate-limit a request by.
trustedHops is how many reverse proxies in front of this server each append the address they received the request from to X-Forwarded-For. 0 (the default) ignores the header and uses the connection's peer: a header the client sets is a header an attacker rotates for a fresh budget per request. With N, the Nth address from the RIGHT is the one the outermost trusted proxy saw — everything left of it is the client's to forge. A header with fewer entries than N, or an entry that is not an IP, falls back to the peer.
func ClientKey ¶ added in v1.2.0
ClientKey is the key a per-client rate limit counts under: ClientIP, with an IPv6 address cut to its /64. One subscriber is routinely handed a whole /64, so a key per full address would give them 2^64 fresh budgets.
func NewCrossOriginProtection ¶ added in v1.2.0
func NewCrossOriginProtection(appBase string) *http.CrossOriginProtection
NewCrossOriginProtection refuses a cross-site state-changing request to the sign-in routes: a page on another site that POSTs (even as text/plain, which needs no CORS preflight) to /auth/register or /auth/login would otherwise create accounts or sign the victim's browser into the attacker's account. It is net/http's CrossOriginProtection (Sec-Fetch-Site, else Origin against Host), trusting the app's own public origin as well, because the frontend may be served from a different origin than the API.
EXACT ORIGIN, not site: Sec-Fetch-Site must be same-origin (or none), and any other value passes only with an Origin equal to the app's own origin. same-site is refused like cross-site, because sibling sites under a shared parent that is not on the Public Suffix List (hosted forge sites are all <name>.sites.reliantlabs.dev) are same-site with each other, and a malicious sibling must not be able to post here. Without Sec-Fetch-Site (an old browser) the Origin must name this host or the app's origin.
Requests with neither header (curl, server-to-server) pass: they carry no browser's cookies, which is what a cross-site request forgery abuses.
func SafeReturnTo ¶ added in v1.2.0
SafeReturnTo keeps a same-origin path and turns anything else into "/".
It refuses what a browser would read as another site: an absolute or scheme-relative URL, a backslash (browsers treat `/\host` as `//host`), any control character (tabs and newlines are stripped by the URL parser, so `/<tab>/host` is `//host`), and a path that RESOLVES to one starting with `//` (`/.//host`, `/a/..//host`, `/%2e//host`).
Types ¶
type Answer ¶ added in v1.2.0
type Answer struct {
// WebAuthnAssertion is the browser's PublicKeyCredential as JSON. The
// CALLER must have checked its clientDataJSON origin and type first
// (CheckClientData): the issuer verifies the signature and the
// challenge, but accepts whatever origin the client reports.
WebAuthnAssertion json.RawMessage
// OTPEmailCode is the emailed one-time code.
OTPEmailCode string
}
Answer is the response to a challenge. Exactly one field is set.
type BrokerCredential ¶
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 Challenge ¶ added in v1.2.0
type Challenge struct {
// WebAuthnDomain requests a passkey assertion for this relying-party
// id (the app's host or a parent of it — never the issuer's).
WebAuthnDomain string
// OTPEmailURLTemplate requests an emailed one-time code; the mail links
// to this template ({{.Code}}, {{.UserID}}, {{.SessionID}} …).
OTPEmailURLTemplate string
// OTPEmailReturnCode requests the one-time code back instead of mailed.
// Only for a caller that has ALREADY verified a proof in the same
// request (an email-verification code the person presented): it is how
// that proof becomes a session.
OTPEmailReturnCode bool
}
Challenge is what a challenge session asks for. Exactly one field is set.
type ChallengeSession ¶ added in v1.2.0
type ChallengeSession struct {
ID string
Token string
// WebAuthnOptions is the passkey challenge (the issuer's
// publicKeyCredentialRequestOptions), for navigator.credentials.get.
WebAuthnOptions json.RawMessage
// OTPEmailCode is set only when the code was requested with ReturnCode.
OTPEmailCode string
}
ChallengeSession is a session the issuer created with a user check and a challenge, waiting for its answer. It is NOT a Session: it cannot be finalized, and holding one proves nothing.
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
// TokenFile, when set, names a file holding the token, read on every use
// so the credential can rotate without a restart. It takes precedence over
// Token while it yields one; Token stays the fallback. See token_file.go.
TokenFile 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) AddIDPLink ¶ added in v1.2.0
AddIDPLink links a provider identity to an existing account. Call it only when BOTH the provider's address and the account's are verified, and they are the same address: an unverified auto-link is an account takeover.
func (*Client) BeginFlow ¶
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) CompleteSignIn ¶ added in v1.2.0
CompleteSignIn turns an authenticated session into tokens: it begins an auth request, checks the session, finalizes and redeems.
THE CHECK is the point. The session is read back from the issuer and refused (ErrNoVerifiedFactor) unless a verifying factor is recorded on it. With cfg.RequireVerifiedEmail it is also refused (ErrEmailUnverified) while the account's address is unverified. Both refusals delete the session.
func (*Client) CreateChallengeSession ¶ added in v1.2.0
func (c *Client) CreateChallengeSession(ctx context.Context, loginName, userID string, ch Challenge) (ChallengeSession, error)
CreateChallengeSession creates a session for a user (by login name or id) and requests one challenge.
func (*Client) CreateSession ¶
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) CreateUser ¶ added in v1.2.0
CreateUser creates a human account and returns its id. A rejected password is a *PasswordPolicyError.
func (*Client) DefaultOrg ¶
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) DeleteMetadata ¶ added in v1.2.0
DeleteMetadata removes one metadata key from an account.
func (*Client) DeleteSession ¶ added in v1.2.0
DeleteSession ends a session at the issuer. Best effort: a caller that refuses a session deletes it so nothing can finalize it later.
func (*Client) EndSessions ¶ added in v1.2.0
EndSessions deletes every issuer session of a user, so nothing created before a revocation can be finalized into tokens after it.
func (*Client) EnsureLoginBroker ¶
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 ¶
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) EnsureOTPEmail ¶ added in v1.2.0
EnsureOTPEmail gives a user the OTP-email method, which a magic link's one-time code is checked against. Idempotent. The issuer refuses it for an unverified address (COMMAND-KLJ2d), so call it only for a verified one.
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 ¶
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) FindUserByID ¶ added in v1.0.0
FindUserByID resolves a user id to the human user it names. ErrUserNotFound when there is none, or when it is a machine account.
func (*Client) GetSessionFactors ¶ added in v1.2.0
GetSessionFactors reads a session back from the issuer.
func (*Client) GrantProjectRole ¶ added in v1.2.0
GrantProjectRole grants role on projectID to a user, additively. An existing grant is left as it is.
This is the management API (user.grant.write). A hosted identity gateway that does not route /management/v1 answers 404; the caller logs it, and the app grants the role itself (`forge cloud identity grant`) until it does.
func (*Client) HasBrokerToken ¶ added in v0.1.46
HasBrokerToken reports whether a token is available from either source. Use it for a boot-time "is sign-in configured" check; it reads the file.
func (*Client) HasMetadata ¶ added in v1.2.0
HasMetadata reports whether an account carries a metadata key.
func (*Client) HasPassword ¶ added in v1.2.0
HasPassword reports whether a user has a password set.
func (*Client) ListIDPLinks ¶ added in v1.2.0
ListIDPLinks returns every identity-provider link of an account, across all of the issuer's result pages.
func (*Client) ListPasskeys ¶ added in v1.2.0
ListPasskeys returns a user's ready passkeys.
func (*Client) LockUser ¶ added in v1.2.0
LockUser locks an account so nothing can sign in to it. An account that is locked already stays so: the issuer's "should be active" refusal is success here, which is what lets a proof re-lock unconditionally.
func (*Client) Metadata ¶ added in v1.2.0
Metadata reads one metadata key of an account: its value, and whether the account carries it.
func (*Client) MintPasskeyRegistrationCode ¶ added in v1.2.0
func (c *Client) MintPasskeyRegistrationCode(ctx context.Context, userID string) (PasskeyRegistrationCode, error)
MintPasskeyRegistrationCode asks the issuer for a passkey registration code and returns it instead of mailing it. LIKE A RESET CODE, IT IS A CREDENTIAL: call it only after a proof for this user succeeded in the same request, and never put it in a response body.
func (*Client) MintPasswordResetCode ¶ added in v1.0.0
MintPasswordResetCode asks the issuer for a reset code and returns it to the CALLER instead of mailing it.
THE RETURNED CODE IS THE CREDENTIAL. Whoever holds it can set the user's password. It belongs in a server log in development, or in a channel the account's owner controls (an SMS, a chat message, an admin screen only they can see) — never in a response to the request that asked for it, which is anyone on the internet. A handler that returned it from a "forgot password" endpoint would turn that endpoint into "take over any account".
func (*Client) PasswordPolicy ¶ added in v1.0.0
func (c *Client) PasswordPolicy(ctx context.Context) (PasswordPolicy, error)
PasswordPolicy reads the instance's password-complexity policy.
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 ¶
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) RegisterPasskey ¶ added in v1.2.0
func (c *Client) RegisterPasskey(ctx context.Context, userID, domain string, code *PasskeyRegistrationCode) (PasskeyRegistration, error)
RegisterPasskey starts a passkey registration for userID under the relying-party id domain. code is nil for a signed-in user (the caller took userID from a validated session token, never from a request body), and the registration code otherwise.
func (*Client) ResendEmailCode ¶ added in v1.2.0
ResendEmailCode mails a fresh email-verification code, linking to urlTemplate ({{.UserID}}, {{.Code}}, {{.OrgID}}).
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) RetrieveIDPIntent ¶ added in v1.2.0
RetrieveIDPIntent reads a completed intent. The token is the one the issuer appended to the success URL; it is useless without the intent id.
func (*Client) RevokeCredentials ¶ added in v1.2.0
RevokeCredentials removes every credential on an account: the password is replaced by a random one nobody holds (when it has one), and every identity provider link and passkey is removed. The account itself, its address and its grants stay. It returns how many credentials it took back.
func (*Client) SendPasswordResetLink ¶ added in v1.0.0
SendPasswordResetLink asks the issuer to EMAIL the user a link to set a new password. urlTemplate is the link's shape, with the issuer's placeholders:
https://app.example.com/auth/reset?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}
── This sends mail only if the instance has an SMTP provider ───────────
The call SUCCEEDS either way. An instance with no SMTP configuration (every customer instance, unless its environment declares `smtp`) accepts the request, queues a notification nothing can deliver, and tells the caller nothing. A nil error therefore means "the issuer accepted the request", never "an email arrived".
The code travels only in the email. This function never returns it, so a request handler that calls it cannot leak one to the person asking.
func (*Client) SetLoginUI ¶
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) SetMetadata ¶ added in v1.2.0
SetMetadata writes one metadata key on an account.
func (*Client) SetPassword ¶ added in v1.2.0
SetPassword sets a user's password with a reset code the broker mints itself. CALL ONLY AFTER A PROOF for this user in the same request: the broker's credential needs no code from the person to do this.
func (*Client) SetSession ¶ added in v1.2.0
SetSession presents the answer to a challenge session and returns the session it became. A rejected one-time code is ErrInvalidCode.
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) StartIDPIntent ¶ added in v1.2.0
func (c *Client) StartIDPIntent(ctx context.Context, idpID, successURL, failureURL string) (authURL, intentID string, err error)
StartIDPIntent starts a social sign-in and returns the provider's authorize URL and the intent id (the URL's `state`), which the caller binds to the browser so the callback can prove it is the same browser.
func (*Client) UnlockUser ¶ added in v1.2.0
UnlockUser lifts LockUser. An account that is not locked is left as it is: the issuer's "not locked" refusal is success here.
func (*Client) UserInfo ¶ added in v1.2.0
UserInfo asks the issuer who an access token belongs to. The issuer validates the token; an invalid or expired one is an error. This is how a signed-in-only route learns its user: from the token, never a request body.
func (*Client) VerifyEmail ¶ added in v1.2.0
VerifyEmail redeems an email-verification code. A wrong, expired or used code is ErrInvalidCode. On success the address is verified.
func (*Client) VerifyInviteCode ¶ added in v1.2.0
VerifyInviteCode redeems an invitation code. Single use; on success the issuer marks the address verified. A rejected code is ErrInvalidCode.
func (*Client) VerifyPasskeyRegistration ¶ added in v1.2.0
func (c *Client) VerifyPasskeyRegistration(ctx context.Context, userID, passkeyID string, credential json.RawMessage, name string) error
VerifyPasskeyRegistration completes a registration with the browser's attestation. Check it with CheckClientData(…, WebAuthnCreate) first.
func (*Client) WaitReady ¶
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 Credentials ¶
type Credentials struct {
// LoginName identifies the account: the username, email or other
// login name the issuer knows. Required unless UserID is set.
LoginName string
// UserID identifies the account by its issuer id instead, for a flow
// that learned the id from a proof (a social intent the issuer
// resolved to a linked user). Exclusive with LoginName.
UserID 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
// IDPIntent is a social sign-in the issuer completed: the intent id and
// token its callback handed back. Verifying on its own — the issuer
// checks that the intent succeeded, has not expired, and belongs to the
// user checked.
IDPIntent *IDPIntent
}
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:
- 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.
- The app collects credentials on its own screen and calls CreateSession — the credential check happens here.
- 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.19.4: 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 ¶
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
// RequireVerifiedEmail refuses to issue tokens for an account whose
// address is unverified (ErrEmailUnverified). The check runs after the
// credentials were verified and before the auth request is finalized,
// so only someone who proved the credentials learns the address state.
RequireVerifiedEmail bool
}
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 GuardedMux ¶ added in v1.2.0
type GuardedMux struct {
// contains filtered or unexported fields
}
GuardedMux mounts every handler behind a CrossOriginProtection. The scaffolded broker mounts its routes through one, so no route can be added unguarded by forgetting a wrapper.
type IDPResult ¶ added in v1.2.0
type IDPResult struct {
// LinkedUserID is the account already linked to this provider identity,
// "" when none is.
LinkedUserID string
// Link is the provider identity, for AddIDPLink or CreateUser.
Link IDPLink
// Email is the provider's address; EmailVerified is the provider's
// assertion that it is verified. An EMPTY Email is never verified,
// whatever the issuer says: Zitadel's GitHub provider reports every
// address verified, even one it did not find.
Email string
EmailVerified bool
GivenName string
FamilyName string
}
IDPResult is what the provider asserted, as the issuer reports it.
type LoginPolicy ¶ added in v1.2.0
type LoginPolicy struct {
// Present is false for the legacy policy: no document, or one that
// could not be read.
Present bool `json:"-"`
Version int `json:"version"`
Signup string `json:"signup"`
SignupDomains []string `json:"signup_domains"`
SignupRole string `json:"signup_role"`
Methods []string `json:"methods"`
PasskeyRPID string `json:"passkey_rp_id"`
Social []LoginPolicySocial `json:"social"`
Mail string `json:"mail"`
// RequireVerifiedEmail: no session is issued for an unverified address.
RequireVerifiedEmail bool `json:"require_verified_email"`
// Password is the instance's complexity policy as last converged, for
// the sign-up checklist. Nil when the document carries none.
Password *LoginPolicyPassword `json:"password,omitempty"`
}
LoginPolicy is the parsed document. The zero value is NOT the legacy policy; use LegacyLoginPolicy.
func LegacyLoginPolicy ¶ added in v1.2.0
func LegacyLoginPolicy() LoginPolicy
LegacyLoginPolicy is the policy every broker served before the document existed: password only, invite-only (the broker's own development and auth_self_signup switches decide sign-up), no verified-email gate.
func ParseLoginPolicy ¶ added in v1.2.0
func ParseLoginPolicy(raw string) (LoginPolicy, error)
ParseLoginPolicy reads the idp_login_policy value.
Empty (or whitespace) is the legacy policy with a nil error. Anything that is not a valid version-1 document is the legacy policy AND an error wrapping ErrLoginPolicyUnreadable, which the caller logs at ERROR.
func (LoginPolicy) MailDelivers ¶ added in v1.2.0
func (p LoginPolicy) MailDelivers() bool
MailDelivers reports whether the instance's mail reaches anyone: the relay, a declared SMTP server, the dev mail catcher, or the sandbox (where an org admin reads it). Paused and none deliver nothing.
func (LoginPolicy) Offers ¶ added in v1.2.0
func (p LoginPolicy) Offers(method string) bool
Offers reports whether method is one the sign-in screen offers.
func (LoginPolicy) PasswordPolicy ¶ added in v1.2.0
func (p LoginPolicy) PasswordPolicy() (PasswordPolicy, bool)
PasswordPolicy returns the document's complexity policy in the shape the sign-up checklist and the rejection body use; ok is false when it carries none.
func (LoginPolicy) RegisterServed ¶ added in v1.2.0
func (p LoginPolicy) RegisterServed() bool
RegisterServed reports whether the sign-up FORM (POST /auth/register) can end in a session under this policy, which is when it is offered at all. Sign-up by mail needs mail that arrives. Where none does (mail "none" or "paused"), only an open sign-up with a password and no verified-email gate can complete: the account is signed in at once, as before the document. A domain sign-up never runs without mail, because a typed address proves no domain. Social sign-up is not the form, and SelfSignup governs it.
func (LoginPolicy) SelfSignup ¶ added in v1.2.0
func (p LoginPolicy) SelfSignup() bool
SelfSignup reports whether the document lets anyone create an account from the sign-in screen (open, or domain-gated).
func (LoginPolicy) SignupAllows ¶ added in v1.2.0
func (p LoginPolicy) SignupAllows(email string) bool
SignupAllows reports whether an address may create an account under this policy. In domain mode its domain must be LISTED: a sub-domain matches only if it is listed itself. Case-insensitive, after IDNA normalisation.
Call it with an address that is (or is about to be) VERIFIED. A typed address proves nothing; the verification that follows does, and the session gate refuses the account until it has happened.
func (LoginPolicy) SocialByIdPID ¶ added in v1.2.0
func (p LoginPolicy) SocialByIdPID(id string) (LoginPolicySocial, bool)
SocialByIdPID returns the button whose identity-provider id is id.
func (LoginPolicy) SocialByType ¶ added in v1.2.0
func (p LoginPolicy) SocialByType(typ string) (LoginPolicySocial, bool)
SocialByType returns the button for a provider type.
type LoginPolicyPassword ¶ added in v1.2.0
type LoginPolicyPassword struct {
MinLength int `json:"min_length"`
Uppercase bool `json:"uppercase"`
Lowercase bool `json:"lowercase"`
Number bool `json:"number"`
Symbol bool `json:"symbol"`
}
LoginPolicyPassword is the password complexity a sign-up form checks.
type LoginPolicySocial ¶ added in v1.2.0
type LoginPolicySocial struct {
Type string `json:"type"`
IdPID string `json:"idp_id"`
Label string `json:"label"`
}
LoginPolicySocial is one social sign-in button.
type NewUser ¶ added in v1.2.0
type NewUser struct {
Email string
GivenName string
FamilyName string
// Password is optional: a passkey- or link-only account has none.
Password string
// EmailVerified is true only when a provider asserted it.
EmailVerified bool
// VerifyURLTemplate is where the verification mail links; "" mails the
// issuer's default link. Ignored when EmailVerified.
VerifyURLTemplate string
// IDPLinks links social accounts at creation.
IDPLinks []IDPLink
// SelfSignup marks the account as created by public sign-up
// (MetaSelfSignup), which is what entitles it to signup_role.
SelfSignup bool
}
NewUser is an account the broker creates from a sign-up: the address is UNVERIFIED unless EmailVerified (asserted by a social provider), and a verification code is mailed to VerifyURLTemplate when it is not.
type Options ¶ added in v1.2.0
type Options struct {
SelfSignup bool `json:"selfSignup"`
Signup string `json:"signup"`
Methods []string `json:"methods"`
Social []OptionsSocial `json:"social"`
Mail bool `json:"mail"`
}
Options is the v2 part of GET /auth/options.
func OptionsFor ¶ added in v1.2.0
func OptionsFor(p LoginPolicy, legacySelfSignup bool) Options
OptionsFor is what GET /auth/options answers for a policy. selfSignup is the legacy switch (development or auth_self_signup), used only when the policy is the legacy one.
type OptionsSocial ¶ added in v1.2.0
OptionsSocial is one social button, without its issuer id.
type PKCE ¶
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.
type PasskeyRegistration ¶ added in v1.2.0
type PasskeyRegistration struct {
PasskeyID string
Options json.RawMessage
}
PasskeyRegistration is a started registration: the passkey id to verify and the creation options for navigator.credentials.create.
type PasskeyRegistrationCode ¶ added in v1.2.0
PasskeyRegistrationCode authorizes registering a passkey for a user who has no session yet (an invitee, a just-verified sign-up). Minted by the broker right after the person presented a proof in the same request.
type PasswordPolicy ¶ added in v1.0.0
type PasswordPolicy struct {
MinLength int `json:"min_length"`
HasUppercase bool `json:"has_uppercase"`
HasLowercase bool `json:"has_lowercase"`
HasNumber bool `json:"has_number"`
HasSymbol bool `json:"has_symbol"`
}
PasswordPolicy is the issuer's password-complexity policy. The JSON tags are the shape the scaffolded broker serves at GET /auth/password-policy.
func (PasswordPolicy) Describe ¶ added in v1.0.0
func (p PasswordPolicy) Describe(rules []PasswordRule) string
Describe joins the requirements for rules into one sentence fragment: "at least 8 characters, an uppercase letter and a number".
func (PasswordPolicy) Requirement ¶ added in v1.0.0
func (p PasswordPolicy) Requirement(rule PasswordRule) string
Requirement renders one rule as the noun phrase a sentence can list: "at least 8 characters", "an uppercase letter".
func (PasswordPolicy) Unmet ¶ added in v1.0.0
func (p PasswordPolicy) Unmet(password string) []PasswordRule
Unmet returns every rule of the policy the password breaks, in a stable order (length, uppercase, lowercase, number, symbol).
The issuer reports only the FIRST rule a password breaks — "Sh0rt!" is answered with "too short" alone, though it also lacks nothing else, and "short" is answered with "too short" though it also lacks an uppercase letter, a number and a symbol. Telling the person all of it at once saves them a round trip per rule, which is why the broker evaluates the policy itself on a rejection rather than relaying the one message.
The classes mirror the issuer's: A-Z, a-z, 0-9, and a symbol is anything else (a space counts — verified against v4.19.5). This is guidance; the issuer decides.
type PasswordPolicyError ¶
type PasswordPolicyError struct {
// Rule is the complexity rule the issuer reported, or "" when it
// reported one this package does not know. The issuer names only the
// first rule a password breaks; PasswordPolicy.Unmet lists them all.
Rule PasswordRule
// 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 ¶
PasswordReset is one redemption of a reset code. Every field is required.
type PasswordRule ¶ added in v1.0.0
type PasswordRule string
PasswordRule names one clause of the complexity policy. The values are the wire spelling the scaffolded frontend reads, so they are part of the contract between a broker and its sign-up form.
const ( RuleMinLength PasswordRule = "min_length" RuleUppercase PasswordRule = "uppercase" RuleLowercase PasswordRule = "lowercase" RuleNumber PasswordRule = "number" RuleSymbol PasswordRule = "symbol" )
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 Session ¶
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 SessionFactors ¶ added in v1.2.0
type SessionFactors struct {
UserID string
// Verified names the verifying factors with a verification time:
// password, webAuthN, intent, totp, otpEmail, otpSms.
Verified []string
}
SessionFactors is what the issuer recorded on a session.
type SignInRoutes ¶ added in v1.2.0
type SignInRoutes struct {
// contains filtered or unexported fields
}
SignInRoutes serves the sign-in methods a LOGIN_POLICY document turns on — passkeys, magic links, social sign-in, invitations, address verification and sign-up per mode — for an app's native login broker.
The app's own internal/app/login_broker.go mounts each handler on its own route (the route constants below), so an app can drop, wrap or replace any one of them. The broker keeps what is the app's: the session cookie (SignedIn), reading it back (SessionToken) and the password checklist (PasswordPolicy, RejectPassword).
── THE RULE EVERY HANDLER HERE FOLLOWS ───────────────────────────────
The broker's credential can do almost anything to any account: set a password, mark an address verified, mint codes, register a passkey. So every privileged call below follows a PROOF that succeeded for the same user IN THE SAME REQUEST — an invitation code, an email-verification code, an emailed one-time code, a passkey assertion, a provider intent, or a session token the issuer validated. The user id a privileged call acts on comes from that proof, never from a request body.
And no route issues a session for an unverified address while the policy requires verified email: CompleteSignIn reads the session back and refuses.
func NewSignInRoutes ¶ added in v1.2.0
func NewSignInRoutes(cfg SignInRoutesConfig) (*SignInRoutes, error)
NewSignInRoutes validates the configuration. It refuses a policy-bearing broker with no AppBase: every mailed link and the passkey origin need it.
func (*SignInRoutes) HandleInviteAccept ¶ added in v1.2.0
func (r *SignInRoutes) HandleInviteAccept(w http.ResponseWriter, req *http.Request)
HandleInviteAccept redeems an invitation and sets the invitee up with the method they chose. NOTHING after the invitation check runs unless it succeeded for this user in this request.
func (*SignInRoutes) HandleMagicConsume ¶ added in v1.2.0
func (r *SignInRoutes) HandleMagicConsume(w http.ResponseWriter, req *http.Request)
HandleMagicConsume redeems the code from a magic link (or typed from the email) in the browser that asked for it. Without that browser's binding cookie it refuses BEFORE touching the issuer, so a link scanner or a forwarded link burns nothing.
func (*SignInRoutes) HandleMagicRequest ¶ added in v1.2.0
func (r *SignInRoutes) HandleMagicRequest(w http.ResponseWriter, req *http.Request)
HandleMagicRequest mails a sign-in link and binds it to THIS browser.
Always 202 with one body, one binding cookie, and one minimum response time: known, unknown, unverified, rate-limited and refused addresses are indistinguishable. The cookie is the same length for every kind of binding because sealing pads it (seal.go); the floor covers the extra issuer calls a real account costs (DefaultResponseFloor). A verified account gets the issuer's one-time-code session; an unverified one (or a new one, where sign-up allows the address) gets an email-verification code instead, because the issuer refuses the one-time-code method for an unverified address. Both links have one shape.
func (*SignInRoutes) HandlePasskeyBegin ¶ added in v1.2.0
func (r *SignInRoutes) HandlePasskeyBegin(w http.ResponseWriter, req *http.Request)
HandlePasskeyBegin requests a passkey challenge for an address. An unknown address (or one the issuer refuses a challenge for) gets a DECOY that is byte-for-byte the shape the issuer emits, in a binding cookie of the same length, after the same minimum time — so neither the answer, the cookie nor the timing says whether the account exists. With no address, the enrolment cookie of an invite or verification names the user.
func (*SignInRoutes) HandlePasskeyFinish ¶ added in v1.2.0
func (r *SignInRoutes) HandlePasskeyFinish(w http.ResponseWriter, req *http.Request)
HandlePasskeyFinish checks a passkey assertion and signs in. The origin and ceremony type are checked HERE, before the issuer, because the issuer accepts whatever origin the client reports.
A decoy and a real binding are answered identically on every failure: the binding is cleared first, whatever follows, and the assertion is checked before the binding is looked at, so neither the status, the body nor a Set-Cookie header says whether the address got a real challenge.
func (*SignInRoutes) HandlePasskeyRegisterBegin ¶ added in v1.2.0
func (r *SignInRoutes) HandlePasskeyRegisterBegin(w http.ResponseWriter, req *http.Request)
HandlePasskeyRegisterBegin starts registering a passkey for the signed-in (or enrolling) user.
func (*SignInRoutes) HandlePasskeyRegisterFinish ¶ added in v1.2.0
func (r *SignInRoutes) HandlePasskeyRegisterFinish(w http.ResponseWriter, req *http.Request)
HandlePasskeyRegisterFinish verifies the browser's attestation.
func (*SignInRoutes) HandlePasskeys ¶ added in v1.2.0
func (r *SignInRoutes) HandlePasskeys(w http.ResponseWriter, req *http.Request)
HandlePasskeys lists the signed-in user's passkeys.
func (*SignInRoutes) HandleRegister ¶ added in v1.2.0
func (r *SignInRoutes) HandleRegister(w http.ResponseWriter, req *http.Request)
HandleRegister creates an account under an open or domain sign-up. It answers 202 with NO session, identically whether or not the address already has an account: the verification mail is what proves the address, and the session comes after it. A chosen password is set only when the browser that registered proves the address (setPendingPassword).
Where no mail can arrive, the policy decides (RegisterServed): sign-up is refused unless it is open, with a password, and the policy does not require a verified address — then registerWithoutMail signs in at once.
func (*SignInRoutes) HandleSocialCallback ¶ added in v1.2.0
func (r *SignInRoutes) HandleSocialCallback(w http.ResponseWriter, req *http.Request)
HandleSocialCallback completes a social sign-in. The frontend's /auth/social/callback page posts the id and token the issuer appended.
A provider identity already linked signs in. Otherwise:
- an account with the provider's address exists: link ONLY when the provider asserts the address verified AND the account's is verified — an unverified auto-link is an account takeover;
- no account: create one where sign-up allows the address (domain mode needs a provider-verified one), else refuse.
func (*SignInRoutes) HandleSocialStart ¶ added in v1.2.0
func (r *SignInRoutes) HandleSocialStart(w http.ResponseWriter, req *http.Request)
HandleSocialStart begins a social sign-in. The intent id goes into a binding cookie; the callback requires it, which defeats login CSRF.
func (*SignInRoutes) HandleVerify ¶ added in v1.2.0
func (r *SignInRoutes) HandleVerify(w http.ResponseWriter, req *http.Request)
HandleVerify redeems an email-verification code. A self-signed-up account gets its signup_role now, never before. An account with no password, where passkeys are offered, may enrol one next (enroll: true).
func (*SignInRoutes) HandleVerifyResend ¶ added in v1.2.0
func (r *SignInRoutes) HandleVerifyResend(w http.ResponseWriter, req *http.Request)
HandleVerifyResend mails a fresh verification link. Always 202.
func (*SignInRoutes) RPID ¶ added in v1.2.0
func (r *SignInRoutes) RPID() string
RPID is the passkey relying-party id in use.
type SignInRoutesConfig ¶ added in v1.2.0
type SignInRoutesConfig struct {
Client *Client
// Flow is the OAuth client the session is finalized into. Its
// RequireVerifiedEmail is set from Policy.
Flow FlowConfig
Policy LoginPolicy
// AppBase is the app's public origin plus base path ("https://app.example.com"),
// from idp_login_uri. Every mailed link and the passkey origin derive from
// it — never from a request header the caller controls.
AppBase string
// ProjectID is the identity project (jwt_audience), which a signup_role
// is granted on. "" grants nothing.
ProjectID string
// CookiePrefix names the binding cookies (<prefix>_pk, _ml, …).
CookiePrefix string
SecureCookie bool
Logger *slog.Logger
// TrustedProxyHops is how many reverse proxies in front of the server
// append to X-Forwarded-For; rate limits key on the client address they
// saw (see ClientIP). 0 uses the connection's peer.
TrustedProxyHops int
// ResponseFloor is the least time HandleMagicRequest and
// HandlePasskeyBegin take, so their timing does not say whether an
// address has an account. 0 is DefaultResponseFloor; negative disables
// it (tests).
ResponseFloor time.Duration
// SignedIn stores tokens as the app's session and returns the identity
// body the browser renders. Required.
SignedIn func(http.ResponseWriter, Tokens) any
// SessionToken returns the request's session access token, "" when none.
// Required.
SessionToken func(*http.Request) string
// PasswordPolicy is the checklist a new password is held to before the
// issuer is asked (nil: none). RejectPassword answers a rejected one.
PasswordPolicy func(context.Context) (PasswordPolicy, error)
RejectPassword func(ctx context.Context, w http.ResponseWriter, password string, err *PasswordPolicyError)
}
SignInRoutesConfig is what the app's broker supplies.
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
// EmailVerified is the issuer's own verdict on Email
// (human.email.isVerified): true only once the address was proven
// reachable, by a verification code or by whoever created the account
// saying so. An account created through public sign-up holds an address
// the person TYPED, so this is false until they verify it.
//
// Check it before binding anything to Email — an invitation, a tenant
// membership, an account merge. Without it, registering someone else's
// address is enough to claim whatever was waiting for them.
EmailVerified bool
// Locked: the issuer refuses every sign-in to the account.
Locked bool
}
User is the subset of an issuer account a sign-in or reset flow needs.
type UserInfo ¶ added in v1.2.0
type UserInfo struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"-"`
Name string `json:"name,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Picture string `json:"picture,omitempty"`
}
UserInfo is the subject of an access token, as the issuer's userinfo endpoint reports it.
The profile claims (Name through Picture) are filled when the token's scopes grant them (`profile`); each is empty otherwise. They are for display: what the person, or an administrator, typed.