auth

package
v1.11.0 Latest Latest
Warning

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

Go to latest
Published: Jul 22, 2026 License: MIT Imports: 26 Imported by: 0

Documentation

Overview

Package auth implements the multitenant authentication core: password hashing, JWT issuance/verification, refresh token rotation, and the per-request Identity carried through ctx.

Subpackage pkg/auth/oidc owns the SSO connectors (Google, GitHub, generic OIDC).

Index

Constants

View Source
const InvitationTTL = 7 * 24 * time.Hour

InvitationTTL is the default lifetime of an invitation. Keep generous (7 days) so users on email-light schedules still arrive in time.

View Source
const LockoutDuration = 15 * time.Minute

LockoutDuration is the wall-clock window a lockout lasts. While active, the gate at the top of Login short-circuits with ErrInvalidCredentials regardless of the password supplied — the constant-time dummy-hash check still runs so timing doesn't leak the lockout state.

View Source
const LockoutThreshold = 5

LockoutThreshold is the number of consecutive failed password attempts that triggers a temporary account lockout. The counter resets to zero on any successful Login. Tuned conservatively: high enough that a typo cluster doesn't lock a legitimate user, low enough that an attacker can't credential-stuff at full rate.

View Source
const MinPasswordLen = 8

MinPasswordLen is the floor enforced at registration. Set intentionally low to avoid frustrating users with a password manager; argon2id covers the brute-force surface.

View Source
const ResetTokenPrefix = "iar_"

ResetTokenPrefix marks an iterion password-reset token (one-shot, short-lived; emailed to the account address).

View Source
const ResetTokenTTL = 60 * time.Minute

ResetTokenTTL bounds how long a reset link stays valid.

View Source
const SessionsCollectionName = "sessions"

SessionsCollectionName is pinned for monitoring/migration tooling.

Variables

View Source
var (
	ErrTokenExpired = errors.New("auth: token expired")
	ErrTokenInvalid = errors.New("auth: token invalid")
	ErrTokenRevoked = errors.New("auth: token revoked")
)

JWT-related sentinel errors.

View Source
var (
	ErrSessionNotFound = errors.New("auth: session not found")
	ErrSessionRevoked  = errors.New("auth: session revoked")
	ErrSessionExpired  = errors.New("auth: session expired")
)

Sentinel errors used by the SessionStore implementations.

View Source
var (
	ErrInvalidCredentials     = errors.New("auth: invalid credentials")
	ErrAccountDisabled        = errors.New("auth: account disabled")
	ErrPasswordChangeRequired = errors.New("auth: password change required")
	ErrPasswordWeak           = errors.New("auth: password too weak")
	ErrSignupClosed           = errors.New("auth: signup is invite-only")
	ErrLinkRequiresConsent    = errors.New("auth: SSO login matched an existing account by email — link explicitly from settings")
	ErrSSORestricted          = errors.New("auth: SSO login is restricted to allow-listed GitHub teams")
	ErrLinkAlreadyOwned       = errors.New("auth: this SSO identity is already linked to a different account")
	ErrInvitationNotFound     = errors.New("auth: invitation not found")
	ErrInvitationMismatch     = errors.New("auth: invitation does not match user")
	ErrTeamNotFound           = errors.New("auth: team not found")
	ErrNotAMember             = errors.New("auth: user is not a member of the team")
	ErrOrgNotFound            = errors.New("auth: org not found")
	ErrNotAnOrgMember         = errors.New("auth: user is not a member of the org")
	ErrNoTeamInOrg            = errors.New("auth: user has no team in the org")
)

Sentinel errors raised by Service. Handlers map them to HTTP statuses (Unauthorized, Forbidden, NotFound, Conflict).

View Source
var ErrInvalidPasswordHash = errors.New("auth: invalid password hash")

ErrInvalidPasswordHash is returned by VerifyPassword when the stored hash is not in the encoded format produced by HashPassword.

View Source
var ErrResetNotFound = errors.New("auth: reset token not found")

ErrResetNotFound is the store's generic miss (callers collapse it into ErrInvalidCredentials — a reset token is a credential).

Functions

func GenerateRandomToken

func GenerateRandomToken(n int) (token string, raw []byte, err error)

GenerateRandomToken returns a base64-url-encoded n-byte secret suitable for invitation tokens, OAuth state, etc. Returns the raw bytes too so callers can hash or fingerprint without re-decoding.

func HashPassword

func HashPassword(pw string) (string, error)

HashPassword returns an argon2id PHC-style encoded hash of pw. The returned string is safe to store as-is (contains the salt and all parameters needed to verify later).

Format: $argon2id$v=19$m=65536,t=2,p=1$<salt-b64>$<key-b64>

func HashRefreshToken

func HashRefreshToken(token string) string

HashRefreshToken returns the hex SHA-256 of a plaintext refresh token. Stored on the Session and consulted at refresh time.

func VerifyPassword

func VerifyPassword(pw, encoded string) (bool, error)

VerifyPassword reports whether pw matches the encoded hash. Uses constant-time compare on the derived key to avoid timing leaks.

func WithIdentity

func WithIdentity(parent context.Context, id Identity) context.Context

WithIdentity returns a child ctx carrying the given Identity. Used by middleware after JWT validation.

Types

type AccessClaims

type AccessClaims struct {
	Email        string `json:"email,omitempty"`
	OrgID        string `json:"org_id,omitempty"`
	OrgRole      string `json:"org_role,omitempty"`
	TeamID       string `json:"team_id,omitempty"`
	Role         string `json:"role,omitempty"`
	IsSuperAdmin bool   `json:"is_super_admin,omitempty"`
	jwt.RegisteredClaims
}

AccessClaims is the body of the access JWT. Embeds RegisteredClaims so iat/exp/jti/iss/aud/sub round-trip via golang-jwt.

type Config

type Config struct {
	Store      identity.Store
	Sessions   SessionStore
	Signer     *JWTSigner
	SignupMode SignupMode
	// GitHubUngrantedPolicy controls a gated GitHub login that matches no
	// allow-listed team: "refuse" (default) or "submitter" (teamless admit).
	GitHubUngrantedPolicy GitHubUngrantedPolicy
	RefreshTTL            time.Duration
	// TrustedAutoLinkProviders is the operator-configured allowlist of
	// OIDC providers whose verified email is safe to auto-link onto a
	// pre-existing password-account user. Empty = no auto-link; a fresh
	// SSO login that finds an existing user by email returns
	// ErrLinkRequiresConsent so the UI can prompt the user to link
	// manually from their settings.
	TrustedAutoLinkProviders []string
	// OrgSSO is the per-tenant SSO provider store (per-org Keycloak rows +
	// GitHub team-gating). Optional; nil disables per-org SSO.
	OrgSSO orgsso.Store
	// Domains is the per-tenant verified email-domain store gating per-org
	// auto-link. Optional; nil disables auto-link.
	Domains orgsso.DomainStore
	Logger  *iterlog.Logger
	// Resets + Mailer + PublicURL enable the password-reset flow and
	// invitation emails (all optional — see Service fields).
	Resets    PasswordResetStore
	Mailer    mail.Mailer
	PublicURL string
}

Config wires the Service.

type GitHubUngrantedPolicy

type GitHubUngrantedPolicy string

GitHubUngrantedPolicy controls what happens to a GitHub SSO login that reaches the deployment while team-gating is active but matches no allow-listed team (orgsso.GitHubTeamGrant). It decouples GitHub admission from the global SignupMode.

const (
	// GitHubUngrantedRefuse rejects the login (ErrSSORestricted). Default —
	// preserves the historical behaviour.
	GitHubUngrantedRefuse GitHubUngrantedPolicy = "refuse"
	// GitHubUngrantedSubmitter admits the user as a teamless account (no
	// personal team), so they can authenticate + submit to the marketplace
	// but have no other rights until an admin adds them to an allow-listed
	// team. This is the public "sign up with GitHub to submit a bot" tier.
	GitHubUngrantedSubmitter GitHubUngrantedPolicy = "submitter"
)

type Identity

type Identity struct {
	UserID string
	Email  string
	// OrgID / OrgRole are the active organization and the principal's
	// role within it. A token minted before the org rollout carries an
	// empty OrgID; gates that need an org derive it from the active
	// team's OrgID and the session self-heals on the next refresh.
	OrgID        string
	OrgRole      identity.OrgRole
	TeamID       string
	Role         identity.Role
	IsSuperAdmin bool
	// Kind distinguishes a real authenticated user from a synthetic,
	// purpose-scoped principal minted by a self-authenticating surface
	// (see IdentityKind). The zero value is a real user.
	Kind IdentityKind
	// JTI is the JWT ID; useful for audit logging and explicit
	// revocation later (we don't revoke access tokens today; we
	// rely on short TTL + refresh rotation).
	JTI string
}

Identity is the authenticated principal extracted from the access JWT. Middleware injects it into the request ctx; handlers retrieve it via FromContext.

func FromContext

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

FromContext returns the Identity carried by ctx and a boolean reporting whether one was set. Handlers that need authentication should check the second return and surface a 401/500 to the caller — never panic. Middleware (RequireAuth) is the right place to gate admission, not a panic in the handler body.

func (Identity) HasOrgRole

func (i Identity) HasOrgRole(want identity.OrgRole) bool

HasOrgRole reports whether the principal has at least the requested role *in their active org*. Super-admins always pass.

func (Identity) HasRole

func (i Identity) HasRole(want identity.Role) bool

HasRole reports whether the principal has at least the requested role *in their active team*. Super-admins always pass.

func (Identity) IsSynthetic added in v0.50.0

func (i Identity) IsSynthetic() bool

IsSynthetic reports whether this principal is a purpose-scoped, self-authenticating identity (webhook/share) rather than a real user. Operator RBAC gates deny synthetic identities by default; an endpoint that intentionally serves one opts in explicitly.

type IdentityKind added in v0.50.0

type IdentityKind string

IdentityKind distinguishes a real authenticated user (JWT or PAT — full role in the active team) from a synthetic, purpose-scoped principal minted by a self-authenticating surface: an inbound webhook, or a config-share editor link. A synthetic identity carries a TeamID + Role so tenant-scoped STORE reads still work, but it must NEVER pass the operator RBAC gates (canViewTeam / canManageTeam / …) — those authorize humans acting on a team's resources. The zero value ("") is a real user, so existing code that builds an Identity without a Kind keeps full behaviour; only the two synthetic authenticators set a non-empty Kind.

const (
	KindUser    IdentityKind = ""        // JWT or PAT — a real user (zero value)
	KindWebhook IdentityKind = "webhook" // inbound-webhook launch trigger
	KindShare   IdentityKind = "share"   // config-share editor link
)

type JWTDenylist

type JWTDenylist interface {
	IsDenied(jti string) bool
}

JWTDenylist is the abstraction the signer uses to look up revoked JTIs. Implementations are expected to be backed by storage with a TTL index at the JWT's exp so the list doesn't grow unbounded. Verify checks IsDenied for every successful parse — an empty implementation (no revocations) is provided as NopDenylist.

type JWTKey

type JWTKey struct {
	ID     string
	Secret []byte
}

JWTKey is a single HS256 signing key, identified by a stable kid so tokens minted under it can be verified after the active key has rotated. kid is stamped into the JWT header at sign time and looked up out of the header at verify time.

type JWTSigner

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

JWTSigner mints and verifies access JWTs across multiple signing keys. The active key (keys[activeKID]) is used for IssueAccess; Verify accepts a token signed by any key in the map, dispatching by the `kid` header. A token that arrives without a kid header is assumed to come from the active key — that lets the first rollout land without breaking already-issued tokens.

func NewJWTSigner

func NewJWTSigner(b64 string, accessTTL time.Duration) (*JWTSigner, error)

NewJWTSigner constructs a signer from a single key (the simple single-secret case). Equivalent to NewJWTSignerMulti with one entry in the map and the same kid marked active. Kept for callers that don't need rotation; new deployments should prefer NewJWTSignerMulti.

func NewJWTSignerMulti

func NewJWTSignerMulti(keys []JWTKey, activeKID string, accessTTL time.Duration, denylist JWTDenylist) (*JWTSigner, error)

NewJWTSignerMulti constructs a signer that signs new tokens with keys[activeKID] and verifies tokens against any key in keys. The caller supplies kids; recommended scheme is monotonically increasing "k0", "k1", "k2" so the active key is obvious in metrics and logs. During a rollover, retire a key by removing it from the map after one AccessTTL has elapsed past the last token it signed.

func (*JWTSigner) AccessTTL

func (s *JWTSigner) AccessTTL() time.Duration

AccessTTL is exposed so callers (auth_routes) can stamp the cookie max-age in lock-step with the access expiry.

func (*JWTSigner) ActiveKID

func (s *JWTSigner) ActiveKID() string

ActiveKID surfaces the active signing key id so operators can verify (via /authz/diagnostics, for example) which key the server is currently minting tokens with.

func (*JWTSigner) IssueAccess

func (s *JWTSigner) IssueAccess(id Identity) (token string, exp time.Time, err error)

IssueAccess produces a freshly-signed access token for the given principal. The JTI is a UUIDv4 captured back into the returned Identity for audit purposes; the kid header is the signer's active key id so Verify can dispatch correctly after rotation.

func (*JWTSigner) Verify

func (s *JWTSigner) Verify(raw string) (Identity, error)

Verify parses + validates a signed JWT, returning the Identity it carries. Errors are returned as a small set of categorized values so the middleware can map them to specific HTTP responses.

type LoginResult

type LoginResult struct {
	User           identity.User
	ActiveOrgID    string
	ActiveOrgRole  identity.OrgRole
	ActiveTeamID   string
	ActiveRole     identity.Role
	AccessToken    string
	AccessExpires  time.Time
	RefreshToken   string
	RefreshExpires time.Time
	Memberships    []identity.Membership
	OrgMemberships []identity.OrgMembership
}

LoginResult bundles the artifacts returned to the caller after a successful login or refresh. The HTTP layer translates these into cookies / JSON.

type MemoryPasswordResetStore

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

func NewMemoryPasswordResetStore

func NewMemoryPasswordResetStore() *MemoryPasswordResetStore

func (*MemoryPasswordResetStore) Consume

func (s *MemoryPasswordResetStore) Consume(_ context.Context, id string, at time.Time) (bool, error)

func (*MemoryPasswordResetStore) Create

func (*MemoryPasswordResetStore) GetByTokenHash

func (s *MemoryPasswordResetStore) GetByTokenHash(_ context.Context, hash string) (PasswordReset, error)

type MemorySessionStore

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

MemorySessionStore is the in-memory SessionStore for tests.

func NewMemorySessionStore

func NewMemorySessionStore() *MemorySessionStore

NewMemorySessionStore returns an empty in-memory store.

func (*MemorySessionStore) CreateSession

func (m *MemorySessionStore) CreateSession(_ context.Context, s Session) error

func (*MemorySessionStore) DeleteExpired

func (m *MemorySessionStore) DeleteExpired(_ context.Context, before time.Time) (int64, error)

func (*MemorySessionStore) GetSessionByTokenHash

func (m *MemorySessionStore) GetSessionByTokenHash(_ context.Context, tokenHash string) (Session, error)

func (*MemorySessionStore) RevokeSession

func (m *MemorySessionStore) RevokeSession(_ context.Context, id string, at time.Time) error

func (*MemorySessionStore) RevokeSessionIfNotRevoked

func (m *MemorySessionStore) RevokeSessionIfNotRevoked(_ context.Context, id string, at time.Time) (bool, error)

func (*MemorySessionStore) RevokeUserSessions

func (m *MemorySessionStore) RevokeUserSessions(_ context.Context, userID string, at time.Time) error

type MongoPasswordResetStore

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

func NewMongoPasswordResetStore

func NewMongoPasswordResetStore(db *mongo.Database) *MongoPasswordResetStore

func (*MongoPasswordResetStore) Consume

func (s *MongoPasswordResetStore) Consume(ctx context.Context, id string, at time.Time) (bool, error)

func (*MongoPasswordResetStore) Create

func (*MongoPasswordResetStore) EnsureSchema

func (s *MongoPasswordResetStore) EnsureSchema(ctx context.Context) error

func (*MongoPasswordResetStore) GetByTokenHash

func (s *MongoPasswordResetStore) GetByTokenHash(ctx context.Context, hash string) (PasswordReset, error)

type MongoSessionStore

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

MongoSessionStore implements SessionStore on Mongo.

func NewMongoSessionStore

func NewMongoSessionStore(db *mongo.Database) *MongoSessionStore

NewMongoSessionStore creates the store; EnsureSessionSchema sets up indexes (TTL on expires_at, unique on token_hash).

func (*MongoSessionStore) CreateSession

func (s *MongoSessionStore) CreateSession(ctx context.Context, sess Session) error

func (*MongoSessionStore) DeleteExpired

func (s *MongoSessionStore) DeleteExpired(ctx context.Context, before time.Time) (int64, error)

func (*MongoSessionStore) EnsureSchema

func (s *MongoSessionStore) EnsureSchema(ctx context.Context) error

EnsureSessionSchema creates the indexes used by the store. Safe to call repeatedly. The TTL index drives Mongo's own expirator so stale sessions disappear without an explicit DeleteExpired sweep.

func (*MongoSessionStore) GetSessionByTokenHash

func (s *MongoSessionStore) GetSessionByTokenHash(ctx context.Context, tokenHash string) (Session, error)

func (*MongoSessionStore) RevokeSession

func (s *MongoSessionStore) RevokeSession(ctx context.Context, id string, at time.Time) error

func (*MongoSessionStore) RevokeSessionIfNotRevoked

func (s *MongoSessionStore) RevokeSessionIfNotRevoked(ctx context.Context, id string, at time.Time) (bool, error)

func (*MongoSessionStore) RevokeUserSessions

func (s *MongoSessionStore) RevokeUserSessions(ctx context.Context, userID string, at time.Time) error

type NopDenylist

type NopDenylist struct{}

NopDenylist accepts every JTI. The signer falls back to it when no explicit denylist is supplied so test setups don't need to wire storage.

func (NopDenylist) IsDenied

func (NopDenylist) IsDenied(string) bool

type PasswordReset

type PasswordReset struct {
	ID         string     `bson:"_id"`
	UserID     string     `bson:"user_id"`
	TokenHash  string     `bson:"token_hash"`
	CreatedAt  time.Time  `bson:"created_at"`
	ExpiresAt  time.Time  `bson:"expires_at"`
	ConsumedAt *time.Time `bson:"consumed_at,omitempty"`
}

PasswordReset is one pending reset at rest. The plaintext token travels only in the email; only the hash is persisted.

type PasswordResetStore

type PasswordResetStore interface {
	Create(ctx context.Context, p PasswordReset) error
	GetByTokenHash(ctx context.Context, hash string) (PasswordReset, error)
	// Consume atomically marks the reset used; ok=false when it was
	// already consumed (replay).
	Consume(ctx context.Context, id string, at time.Time) (bool, error)
}

PasswordResetStore persists pending resets. Mongo in production; memory for tests/local. Keep semantics in lock-step.

type Service

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

Service is the high-level entry point for authentication and identity-mutation flows. Handlers in pkg/server depend on this type, not directly on the Mongo stores, so tests can swap in memory-backed implementations.

func NewService

func NewService(cfg Config) (*Service, error)

NewService validates the config and returns a wired Service.

func (*Service) AcceptInvitationForExistingUser

func (s *Service) AcceptInvitationForExistingUser(ctx context.Context, userID, token string) (identity.Membership, error)

AcceptInvitationForExistingUser is the path used when an invited email already corresponds to a registered user — they accept by adding a membership.

func (*Service) ChangePassword

func (s *Service) ChangePassword(ctx context.Context, userID, currentPassword, newPassword, userAgent, ip string) (LoginResult, error)

ChangePassword is the authenticated self-service rotation: verify the current password, set the new one, revoke every other session, and re-issue a login so the caller's session continues seamlessly.

func (*Service) ChangePasswordPending

func (s *Service) ChangePasswordPending(ctx context.Context, email, currentPassword, newPassword, userAgent, ip string) (LoginResult, error)

ChangePasswordPending completes the forced-rotation flow for a user in pending_password_change status (e.g. the bootstrapped super-admin): it verifies the temporary password, sets the chosen new one, marks the account active, and issues a normal session. It applies ONLY to pending_password_change accounts — rotating an already-active account's password is a separate, authenticated concern — and returns the opaque ErrInvalidCredentials for a missing user, wrong status, or bad temp password so the endpoint cannot enumerate accounts or their state.

func (*Service) ConfirmPasswordReset

func (s *Service) ConfirmPasswordReset(ctx context.Context, token, newPassword, userAgent, ip string) (LoginResult, error)

ConfirmPasswordReset redeems a one-shot token: sets the new password, revokes every live session, and issues a fresh login.

func (*Service) CreateInvitation

func (s *Service) CreateInvitation(ctx context.Context, teamID, email string, role identity.Role, invitedBy string) (token string, inv identity.Invitation, err error)

CreateInvitation issues a fresh invitation. The plaintext token is returned (caller emails it) and only its hash is persisted.

func (*Service) CreateOrgFor

func (s *Service) CreateOrgFor(ctx context.Context, userID, name, slug string) (identity.Org, error)

CreateOrgFor provisions a new (non-personal) org owned by user `userID`, with an owner org-membership and a default team inside it. Returns the new org. Used by the super-admin org console and org self-service "create organization" flows. If slug is empty it is derived from name and uniquified with a numeric suffix on collision.

func (*Service) CreateTeamFor

func (s *Service) CreateTeamFor(ctx context.Context, userID, orgID, name, slug string) (identity.Team, error)

CreateTeamFor provisions a non-personal team inside org `orgID`, owned by user `userID`. The user must be a member of the org (super- admins may create in any org). Returns the new team. If slug is empty it is derived from name and uniquified with a numeric suffix on collision.

func (*Service) CreateUserAndPersonalTeam

func (s *Service) CreateUserAndPersonalTeam(ctx context.Context, email, name, password string, isSuperAdmin bool, status identity.UserStatus) (identity.User, identity.Team, error)

CreateUserAndPersonalTeam is used by the bootstrap path to provision the very first super-admin. Idempotent: returns the existing user if email is already taken.

func (*Service) DeleteOrgCascade

func (s *Service) DeleteOrgCascade(ctx context.Context, orgID string) error

DeleteOrgCascade removes an org and all its identity-scoped children: every team in the org (each team's memberships + pending invitations, then the team record), then all org memberships, then the org itself.

It deliberately does NOT purge team-scoped resources living in OTHER stores (runs, board, forge connections, SSO providers, secrets) — those become orphaned and unreachable rather than deleted. Intended for super-admin cleanup of empty / migrated orgs; the caller (admin route) gates on super-admin and refuses to delete the caller's active org.

func (*Service) EmailEnabled

func (s *Service) EmailEnabled() bool

EmailEnabled reports whether a real mailer is wired (drives the SPA's forgot-password entry point via server_info).

func (*Service) LinkExternalToUser

func (s *Service) LinkExternalToUser(ctx context.Context, ext oidc.ExternalUser, userID string) error

LinkExternalToUser attaches a freshly-authenticated external identity to an already-signed-in user (the explicit-consent path that resolves the 409 ErrLinkRequiresConsent dead-end: log in with your password, then connect SSO from settings). Idempotent if the identity is already this user's; refuses with ErrLinkAlreadyOwned if it belongs to a different account so an SSO identity can never be silently re-pointed.

func (s *Service) ListSSOLinks(ctx context.Context, userID string) ([]identity.OIDCLink, error)

ListSSOLinks returns the SSO identities linked to a user, for the "connected accounts" settings view.

func (*Service) Login

func (s *Service) Login(ctx context.Context, email, password, userAgent, ip string) (LoginResult, error)

Login authenticates with email + password. On success, issues an access JWT bound to the user's default team (or first available) and a refresh token.

func (*Service) LoginWithExternal

func (s *Service) LoginWithExternal(ctx context.Context, ext oidc.ExternalUser, userAgent, ip string) (LoginResult, error)

LoginWithExternal completes an OIDC/OAuth flow. It either:

  • finds an existing user via OIDCLink → logs them in,
  • finds a user by email → links the new identity to them,
  • in SignupOpen mode, creates a fresh user (+ personal team),
  • in SignupInviteOnly mode, returns ErrSignupClosed unless the user was already provisioned.

func (*Service) LoginWithExternalForOrg

func (s *Service) LoginWithExternalForOrg(ctx context.Context, ext oidc.ExternalUser, tenantID, providerID, userAgent, ip string) (LoginResult, error)

LoginWithExternalForOrg completes a per-org OIDC flow (a tenant's own Keycloak, slug "oidc-org-<providerID>"). The org admin has explicitly designated this IdP as the org's trust root, so a NEW email is onboarded into the org regardless of the global SignupMode — but an SSO identity is NEVER auto-linked onto a pre-existing iterion account by email (that needs JWKS ID-token verification + explicit consent, a Phase-3 hardening; until then → ErrLinkRequiresConsent). The resolved user is granted membership in tenantID at the provider's DefaultRole (grant-only, never downgraded, capped below owner) and lands in that org.

func (*Service) Logout

func (s *Service) Logout(ctx context.Context, presented string) error

Logout revokes the presented refresh token only. Other devices (other refresh tokens for the same user) keep their sessions.

func (*Service) Mailer

func (s *Service) Mailer() mail.Mailer

Mailer exposes the wired mailer (nil-safe for callers that send optional notifications like invitation emails).

func (*Service) MarkOrgForDeletion

func (s *Service) MarkOrgForDeletion(ctx context.Context, orgID string, grace time.Duration) (identity.Org, error)

MarkOrgForDeletion soft-deletes an org: Status=pending_deletion and PurgeAfter=now+grace. The org is blocked immediately (Suspended()==true); the nightly sweeper hard-purges it once PurgeAfter passes. Restorable until then via RestoreOrg.

func (*Service) PublicURL

func (s *Service) PublicURL() string

PublicURL is the externally-reachable base URL used in email links.

func (*Service) Refresh

func (s *Service) Refresh(ctx context.Context, presented, userAgent, ip string) (LoginResult, error)

Refresh rotates a presented refresh token. Returns a LoginResult the caller can re-set the cookies / response with.

func (*Service) Register

func (s *Service) Register(ctx context.Context, email, password, name, invitationToken, userAgent, ip string) (LoginResult, error)

Register creates a new user. When SignupMode is invite_only, an invitation token must be supplied and matching the email.

func (*Service) RequestPasswordReset

func (s *Service) RequestPasswordReset(ctx context.Context, email string) error

RequestPasswordReset mints a one-shot reset token and emails the link. ALWAYS returns nil — account enumeration via this endpoint must be impossible. Unknown emails are silent even server-side (logging them would let an attacker flood the logs with arbitrary PII); disabled accounts and store failures are logged server-side only. No-op (logged) when the reset store or mailer isn't wired.

func (*Service) RestoreOrg

func (s *Service) RestoreOrg(ctx context.Context, orgID string) (identity.Org, error)

RestoreOrg cancels a pending deletion, returning the org to active. It is a no-op (returns the org unchanged) when the org isn't pending deletion.

func (*Service) RevokeUserSessions

func (s *Service) RevokeUserSessions(ctx context.Context, userID string) error

RevokeUserSessions invalidates every live refresh session for the user. Used by the admin "disable user" flow so the user loses access at the next access-token expiry (≤15 min) instead of waiting for refresh TTL (~30 days). Best-effort: a store-write failure is surfaced to the caller, which typically logs and continues.

func (*Service) Store

func (s *Service) Store() identity.Store

Store returns the underlying identity store. Server handlers reach for it to perform read-only joins (e.g. resolving team names from membership rows) without proxying every accessor through the Service.

func (*Service) SwitchOrg

func (s *Service) SwitchOrg(ctx context.Context, userID, orgID string) (Identity, string, time.Time, error)

SwitchOrg re-issues the access JWT bound to a different org. It picks an active team within that org (the user's default team if it belongs to the org, else the first team they're granted there) so the session always lands on a concrete workspace. A user with no team in the org still switches — they land org-scoped with an empty active team (the UI offers to create one). Validates org membership (super-admins step into any org).

func (*Service) SwitchTeam

func (s *Service) SwitchTeam(ctx context.Context, userID, teamID string) (Identity, string, time.Time, error)

SwitchTeam re-issues the access JWT bound to teamID. Validates that the current user is a member of the team — and, transitively, of its parent org (you cannot hold a team grant without an org membership, so the team check is sufficient; super-admins step into either).

func (*Service) UnlinkExternal

func (s *Service) UnlinkExternal(ctx context.Context, userID, provider, providerUserID string) error

UnlinkExternal removes one SSO identity from a user. Ownership is enforced: the link must belong to userID (a user can only detach their own identities).

type Session

type Session struct {
	ID            string     `bson:"_id" json:"id"`
	UserID        string     `bson:"user_id" json:"user_id"`
	TokenHash     string     `bson:"token_hash" json:"-"`
	UserAgent     string     `bson:"user_agent,omitempty" json:"user_agent,omitempty"`
	IP            string     `bson:"ip,omitempty" json:"ip,omitempty"`
	IssuedAt      time.Time  `bson:"issued_at" json:"issued_at"`
	ExpiresAt     time.Time  `bson:"expires_at" json:"expires_at"`
	RevokedAt     *time.Time `bson:"revoked_at,omitempty" json:"revoked_at,omitempty"`
	RotatedFromID string     `bson:"rotated_from,omitempty" json:"-"`
}

Session is a stored refresh token. The plaintext token is never persisted — only its SHA-256 hash. On rotation, the previous session is marked Revoked and a new one is created.

func IssueSession

func IssueSession(ctx context.Context, store SessionStore, userID, userAgent, ip string, ttl time.Duration) (token string, sess Session, err error)

IssueSession generates a fresh refresh token, persists the hashed session, and returns the plaintext token to the caller. The caller is responsible for setting the cookie / sending it to the client.

type SessionStore

type SessionStore interface {
	CreateSession(ctx context.Context, s Session) error
	GetSessionByTokenHash(ctx context.Context, tokenHash string) (Session, error)
	RevokeSession(ctx context.Context, id string, at time.Time) error
	// RevokeSessionIfNotRevoked is a compare-and-set on revoked_at:
	// the write only lands when the field is currently absent.
	// Returns revoked=true when this call performed the revocation,
	// false when the session had already been revoked by a concurrent
	// caller. Used by Refresh to prevent a TOCTOU where two parallel
	// refresh attempts both pass the "not revoked" check and both
	// proceed to mint a fresh access token from the same refresh.
	RevokeSessionIfNotRevoked(ctx context.Context, id string, at time.Time) (revoked bool, err error)
	RevokeUserSessions(ctx context.Context, userID string, at time.Time) error
	DeleteExpired(ctx context.Context, before time.Time) (int64, error)
}

SessionStore is the persistence interface for refresh tokens.

type SignupMode

type SignupMode string

SignupMode controls who may register without an invitation.

const (
	SignupOpen       SignupMode = "open"
	SignupInviteOnly SignupMode = "invite_only"
)

Directories

Path Synopsis
Package desktopsso holds the single-use, TTL-bounded ticket store that carries a login result between the OIDC callback (which MINTS a ticket for a desktop SSO flow) and the desktop exchange endpoint (which REDEEMS it).
Package desktopsso holds the single-use, TTL-bounded ticket store that carries a login result between the OIDC callback (which MINTS a ticket for a desktop SSO flow) and the desktop exchange endpoint (which REDEEMS it).
Package oidc owns the SSO connectors: Google, GitHub, and a generic OIDC discovery-based provider.
Package oidc owns the SSO connectors: Google, GitHub, and a generic OIDC discovery-based provider.
Package orgsso owns the per-tenant (per-org) SSO provider configuration: the rows an iterion org admin self-serves to enable login via their own Keycloak (a discovery-based OIDC provider) or to gate the deployment-level GitHub login on specific GitHub teams.
Package orgsso owns the per-tenant (per-org) SSO provider configuration: the rows an iterion org admin self-serves to enable login via their own Keycloak (a discovery-based OIDC provider) or to gate the deployment-level GitHub login on specific GitHub teams.
Package wsticket holds the single-use, short-TTL ticket store that lets a client open an authenticated WebSocket without carrying a long-lived access JWT in the URL (query strings leak to access logs, proxies, and Referer).
Package wsticket holds the single-use, short-TTL ticket store that lets a client open an authenticated WebSocket without carrying a long-lived access JWT in the URL (query strings leak to access logs, proxies, and Referer).

Jump to

Keyboard shortcuts

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