Documentation
¶
Overview ¶
Package singpass wires the FAPIgo relying-party engine (github.com/idfoundry/fapigo/client) to talk to the Singpass / Corppass FAPI 2.0 profile. FAPIgo does all of the protocol work; this package supplies the pieces FAPIgo leaves to the embedder — a key manager, a keys.Decrypter for the encrypted id_token, and a session store — and encodes the Singpass-specific choices (PAR parameters, algorithm suite, /userinfo handling) so a relying party can integrate Singpass Login, Myinfo, and Myinfo Business without rediscovering them.
Unofficial: this is a community library, not affiliated with or endorsed by GovTech, Singpass or Corppass.
Most callers should use the per-product constructors NewLogin, NewMyinfo, and NewMyinfoBusiness (see products.go), which take just key material and a client_id. New is the lower-level constructor for full control or a non-standard product.
A login is BeginLogin, which returns the redirect URL and the login's state to keep with the browser, then Complete at the callback, given that state.
The defaults target staging. Set Environment: Production in the product options for the production issuers and AssuranceProduction, which requires a durable Dependencies.Sessions store (see the sqlstore package) and durable keys (Dependencies.KeyCustody). A stale or replayed login is reported as ErrLoginExpired and a cancelled or denied one as *DeniedError; ErrorCode and IsTemporary read the error Singpass or Corppass sent.
Index ¶
- Constants
- Variables
- func CheckPublishedJWKS(ctx context.Context, hc *http.Client, jwksURL string, want []byte) error
- func ErrorCode(err error) string
- func IsTemporary(err error) bool
- func OfflineClientJWKS(ctx context.Context, sigKey *ecdsa.PublicKey, sigKID string, ...) ([]byte, error)
- func OfflineJWKS(ctx context.Context, signing, encryption []PublishedKey) ([]byte, error)
- type ActingParty
- type Algorithms
- type AssuranceLevel
- type Client
- func New(ctx context.Context, opts Options, deps Dependencies) (*Client, error)
- func NewLogin(ctx context.Context, o LoginOptions, deps Dependencies) (*Client, error)
- func NewMyinfo(ctx context.Context, o MyinfoOptions, deps Dependencies) (*Client, error)
- func NewMyinfoBusiness(ctx context.Context, o MyinfoBusinessOptions, deps Dependencies) (*Client, error)
- func (c *Client) BeginLogin(ctx context.Context) (redirectURL string, state string, err error)
- func (c *Client) BeginLoginWith(ctx context.Context, lc LoginContext) (redirectURL string, state string, err error)
- func (c *Client) CheckPublishedJWKS(ctx context.Context, jwksURL string) error
- func (c *Client) Complete(ctx context.Context, rawQuery, state string) (*Identity, error)
- func (c *Client) PublicJWKS(ctx context.Context) ([]byte, error)
- type Clock
- type Decrypter
- type DecryptionKey
- type DeniedError
- type Dependencies
- type ECDHAgreer
- type Environment
- type Identity
- func (id *Identity) ActingParty() *ActingParty
- func (id *Identity) AssuranceContext() string
- func (id *Identity) AssuranceLevel() string
- func (id *Identity) Audience() []string
- func (id *Identity) AuthMethods() []string
- func (id *Identity) Issuer() string
- func (id *Identity) SubjectAttributes() SubjectAttributes
- func (id *Identity) SubjectType() string
- type KeyCustody
- type KeyManager
- type Limits
- type LoginContext
- type LoginOptions
- type MyinfoBusinessOptions
- type MyinfoOptions
- type Options
- type PublishedKey
- type ServerErrorResponse
- type SessionStore
- type StoreAssurance
- type SubjectAttributes
Examples ¶
Constants ¶
const ( // AssuranceDevelopment accepts non-durable stores such as the in-memory // default. It is what the zero Dependencies.Assurance selects. AssuranceDevelopment = client.AssuranceDevelopment // AssuranceProduction refuses a SessionStore that doesn't declare itself // durable and atomic, so a deployment can't silently ship the in-memory one. AssuranceProduction = client.AssuranceProduction )
const ( // StagingSingpassIssuer is the Singpass FAPI 2.0 staging issuer (Login + Myinfo). StagingSingpassIssuer = "https://stg-id.singpass.gov.sg/fapi" // StagingCorppassIssuer is the Corppass FAPI 2.0 staging issuer (Myinfo // Business). Note it has no "/fapi" path suffix, unlike Singpass, and it is // a separate authorization server with its own discovery document. StagingCorppassIssuer = "https://stg-id.corppass.gov.sg" // ProductionSingpassIssuer is the Singpass FAPI 2.0 production issuer. ProductionSingpassIssuer = "https://id.singpass.gov.sg/fapi" // ProductionCorppassIssuer is the Corppass FAPI 2.0 production issuer. ProductionCorppassIssuer = "https://id.corppass.gov.sg" )
Issuer identifiers. The product options pick one from Environment; set Issuer only for a non-standard deployment.
const DefaultAuthContextType = "APP_AUTHENTICATION_DEFAULT"
DefaultAuthContextType is the standard Singpass/Corppass Login authentication_context_type — the flow a plain login authenticates for.
const DefaultMaxPendingLogins = 10_000
DefaultMaxPendingLogins caps how many started-but-unfinished logins the in-memory session store holds at once when NewMemorySessionStore is given 0. At FAPIgo's 5-minute session lifetime that allows ~33 new logins a second, sustained, before new ones are refused.
Variables ¶
var ErrLoginExpired = errors.New("singpass: login expired or already used")
ErrLoginExpired is returned (wrapped) by Client.Complete when the callback's state is unknown, already used or past its lifetime — typically the user took too long, pressed back, or reloaded the callback page, or the process restarted mid-login. It is not a protocol failure: show a "please try again" page, e.g. with errors.Is(err, singpass.ErrLoginExpired). A custom SessionStore should return (or wrap) it from Consume in the same cases.
var ErrTooManyPendingLogins = errors.New("singpass: too many pending logins")
ErrTooManyPendingLogins is returned (wrapped) by BeginLogin when the in-memory session store is at its cap. It means too many logins have been started and not finished within the session lifetime — typically someone hitting the login route in a loop — and a caller may map it to 503.
Functions ¶
func CheckPublishedJWKS ¶ added in v0.8.0
CheckPublishedJWKS is Client.CheckPublishedJWKS for when there is no Client yet, e.g. during onboarding: it checks the JWKS at jwksURL against want, a JWKS such as OfflineClientJWKS returns. A nil hc uses a client with a 15-second timeout. jwksURL must use https, except on a loopback host.
func ErrorCode ¶ added in v0.9.0
ErrorCode returns the OAuth error code Singpass or Corppass sent behind err, e.g. "invalid_client" or "server_error", or "" when there is none. docs/troubleshooting.md lists what each code means.
func IsTemporary ¶ added in v0.9.0
IsTemporary reports whether err is a temporary failure worth trying again: Singpass or Corppass answered server_error, temporarily_unavailable or upstream_dependency_error (Singpass allows up to 3 retries with exponential backoff), or a 5xx without an error code, or the request timed out.
What to try again depends on the call. BeginLogin can simply be called again (Dependencies.BeginLoginRetries does it for you). A Complete failure can't be: the authorization code and login state are already used, so restart the login from BeginLogin instead.
func OfflineClientJWKS ¶
func OfflineClientJWKS(ctx context.Context, sigKey *ecdsa.PublicKey, sigKID string, encKey *ecdsa.PublicKey, encKID string) ([]byte, error)
OfflineClientJWKS builds the public JWKS a relying party publishes during onboarding (the client-authentication signing key + the id_token/userinfo encryption key), without contacting the authorization server. Key-generation tooling runs before any client_id or discovery document exists, so there is no Client to call Client.PublicJWKS on yet.
It takes public keys only — a JWKS publishes nothing else — so an HSM/KMS deployment can produce it from the keys' exported public halves; with an in-memory key pass &priv.PublicKey.
FAPIgo's keys.PublicJWKS assembles the set from key material and the declared algorithms — no client engine, issuer or endpoints required — and it is the same library code the live client's PublicJWKS resolves through, so the offline and online sets are produced identically: the signing key under the ClientAuthentication purpose and the encryption key under IDTokenDecryption (the DPoP key is deliberately not published).
Example ¶
Generate the two keys a client registers during onboarding and print the public JWKS to submit — no client_id or network access needed yet.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
singpass "github.com/osanderson/singpass-client-go"
"github.com/osanderson/singpass-client-go/keyfile"
)
func main() {
sig, _ := keyfile.GenerateECKey()
enc, _ := keyfile.GenerateECKey()
// Persist both with keyfile.MarshalECPrivateKeyPEM (file mode 0600).
jwks, err := singpass.OfflineClientJWKS(context.Background(), &sig.PublicKey, "login-sig-1", &enc.PublicKey, "login-enc-1")
if err != nil {
log.Fatal(err)
}
var set struct {
Keys []struct {
Kid string `json:"kid"`
Use string `json:"use"`
Crv string `json:"crv"`
} `json:"keys"`
}
if err := json.Unmarshal(jwks, &set); err != nil {
log.Fatal(err)
}
for _, k := range set.Keys {
fmt.Println(k.Kid, k.Use, k.Crv)
}
}
Output: login-sig-1 sig P-256 login-enc-1 enc P-256
func OfflineJWKS ¶ added in v0.8.0
func OfflineJWKS(ctx context.Context, signing, encryption []PublishedKey) ([]byte, error)
OfflineJWKS is OfflineClientJWKS for any number of keys, e.g. to publish a static JWKS during a key rotation (see docs/production.md): every signing and encryption key is published under its kid, and the result equals Client.PublicJWKS for a client holding those keys, with the first of each list as the current key.
Types ¶
type ActingParty ¶
type ActingParty struct {
Subject string // act.sub — the acting person's Singpass identifier
SubjectType string // act.sub_type — typically "user"
// Attributes is act.sub_attributes, describing the person: Corppass sends
// AccountType, IdentityNumber, IdentityCOI and Name.
Attributes SubjectAttributes
}
ActingParty is the "act" (actor) claim: for Corppass, the person who authenticated on behalf of the entity, distinct from the entity Subject.
type Algorithms ¶ added in v0.2.0
type Algorithms = client.Algorithms
Algorithms is the JOSE algorithm suite: Dependencies.Algorithms. Leave it nil for the Singpass/Corppass suite.
type AssuranceLevel ¶ added in v0.2.0
type AssuranceLevel = client.AssuranceLevel
AssuranceLevel selects how strictly the client vets its dependencies: Dependencies.Assurance.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is a relying party (Login, Myinfo, or Myinfo Business). It is safe for concurrent use.
func New ¶
New discovers the FAPI metadata and constructs a ready-to-use relying-party client. deps.Keys and deps.Decryption are required; every other Dependencies field defaults to a staging-appropriate value when left zero (see Dependencies). Most callers should prefer NewLogin / NewMyinfo / NewMyinfoBusiness.
func NewLogin ¶
func NewLogin(ctx context.Context, o LoginOptions, deps Dependencies) (*Client, error)
NewLogin constructs a Login relying party. It fills in the Login-specific choices (authentication_context_type required, no /userinfo call) and, when deps.Keys / deps.Decryption are nil, builds them from the option's key material. Everything else follows Dependencies' staging defaults.
Example ¶
A Singpass Login client needs only its registered keys, client_id and redirect URI; everything else defaults to Singpass staging.
package main
import (
"context"
"log"
singpass "github.com/osanderson/singpass-client-go"
"github.com/osanderson/singpass-client-go/keyfile"
)
func main() {
sigKey, err := keyfile.LoadECPrivateKey("keys/login/sig.pem")
if err != nil {
log.Fatal(err)
}
encKey, err := keyfile.LoadECPrivateKey("keys/login/enc.pem")
if err != nil {
log.Fatal(err)
}
client, err := singpass.NewLogin(context.Background(), singpass.LoginOptions{
ClientID: "your-client-id",
RedirectURI: "https://app.example.com/login/callback",
Scopes: []string{"openid", "name", "email"},
SigningKey: sigKey, // any crypto.Signer, e.g. an HSM/KMS key
SigningKID: "login-sig-1",
EncryptionKey: encKey,
EncryptionKID: "login-enc-1",
}, singpass.Dependencies{})
if err != nil {
log.Fatal(err)
}
_ = client
}
Output:
func NewMyinfo ¶
func NewMyinfo(ctx context.Context, o MyinfoOptions, deps Dependencies) (*Client, error)
NewMyinfo constructs a Myinfo relying party (authentication + /userinfo person data). It presets FetchUserInfo and omits authentication_context_type.
func NewMyinfoBusiness ¶
func NewMyinfoBusiness(ctx context.Context, o MyinfoBusinessOptions, deps Dependencies) (*Client, error)
NewMyinfoBusiness constructs a Myinfo Business (Corppass) relying party. It presets FetchUserInfo and defaults to the Corppass issuer.
func (*Client) BeginLogin ¶
BeginLogin starts an authorization attempt: it runs the pushed authorization request and returns the URL to redirect the browser to, plus the login's state. Keep the state with the browser — typically in an HttpOnly, SameSite=Lax cookie — and pass it to Complete at the callback: it binds the callback to the browser that started the login. With Dependencies.BeginLoginRetries set, a temporary failure is retried.
func (*Client) BeginLoginWith ¶ added in v0.10.0
func (c *Client) BeginLoginWith(ctx context.Context, lc LoginContext) (redirectURL string, state string, err error)
BeginLoginWith is BeginLogin for a Singpass Login client that describes this particular login, e.g. LoginContext{Message: "Approve your transfer of $500"}; its fields override the client's for this login only. Myinfo clients take no login context.
func (*Client) CheckPublishedJWKS ¶ added in v0.8.0
CheckPublishedJWKS fetches the JWKS at jwksURL — the JWKS URL registered for this client in the Singpass or Corppass developer portal — and checks that it publishes this client's keys (Client.PublicJWKS): every key present under its kid, with the same public key and use, and no private key material. A mismatch there is the usual cause of an "invalid_client" error at login, so run it after deploying or rotating keys, e.g. as a startup or readiness check. It also checks the endpoint answers within the 3 seconds Singpass and Corppass allow, without redirecting. It returns nil when the published set is correct, or an error naming each problem. Extra published keys, such as an outgoing key during a rotation, are allowed.
func (*Client) Complete ¶
Complete validates the authorization callback (identified by its raw query string), exchanges the code for tokens, and returns the authenticated identity. state is the value BeginLogin returned, recovered from the browser (e.g. its cookie) — never from the callback itself — so a callback URL delivered to another browser can't complete this login (RFC 9700 §4.7). A callback without a matching state, or a stale one (expired, reloaded or replayed), matches errors.Is(err, ErrLoginExpired); a user-declined or server-denied login is reported as a *DeniedError.
Example ¶
The whole flow with plain net/http: BeginLogin on the way out, Complete on the callback. The state handle ties the callback to the browser that started the login. (The web package does all of this for you.)
package main
import (
"errors"
"fmt"
"net/http"
singpass "github.com/osanderson/singpass-client-go"
)
func main() {
var client *singpass.Client // from NewLogin / NewMyinfo / NewMyinfoBusiness
http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
redirectURL, state, err := client.BeginLogin(r.Context())
if err != nil {
http.Error(w, "login unavailable", http.StatusBadGateway)
return
}
http.SetCookie(w, &http.Cookie{
Name: "sp_state", Value: state, Path: "/login",
MaxAge: 600, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, redirectURL, http.StatusFound)
})
http.HandleFunc("/login/callback", func(w http.ResponseWriter, r *http.Request) {
var state string // the state BeginLogin returned, from this browser's cookie
if c, err := r.Cookie("sp_state"); err == nil {
state = c.Value
}
id, err := client.Complete(r.Context(), r.URL.RawQuery, state)
var denied *singpass.DeniedError
switch {
case errors.Is(err, singpass.ErrLoginExpired):
http.Error(w, "login expired, please try again", http.StatusBadRequest)
return
case errors.As(err, &denied):
fmt.Fprintf(w, "login cancelled (%s)", denied.Code)
return
case err != nil:
http.Error(w, "login failed", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "hello %s", id.Subject)
})
}
Output:
func (*Client) PublicJWKS ¶
PublicJWKS returns this client's public JWK Set — the signing key the server uses to verify the private_key_jwt client assertion, and the encryption key it encrypts the id_token/userinfo to. FAPIgo assembles it (client.PublicJWKS) from the KeyManager and the keys.Decrypter this client already holds.
type Decrypter ¶ added in v0.2.0
Decrypter decrypts the id_token and /userinfo JWEs: Dependencies.Decryption. Build one with NewECDHDecrypter or NewAgreerDecrypter.
func NewAgreerDecrypter ¶
func NewAgreerDecrypter(agreer ECDHAgreer) (Decrypter, error)
NewAgreerDecrypter builds the keys.Decrypter from a caller-supplied keys.ECDHAgreer instead of an in-memory private key. This is the ergonomic seam for an HSM/KMS deployment: the agreer performs the ECDH key-agreement step (an HSM's CKM_ECDH1_DERIVE or a managed KMS's DeriveSharedSecret) without the encryption private key ever entering this process, while FAPIgo still owns the Concat-KDF + RFC-3394 unwrap. It parallels NewKeyManager accepting a crypto.Signer, so hardware-backed decryption sits on the same footing as hardware-backed signing. One decrypter serves both the id_token and the /userinfo response. There is no kid parameter: the agreer reports its own key id (keys.RecipientKey), which is what the published JWKS and JWE "kid" matching use.
func NewECDHDecrypter ¶
func NewECDHDecrypter(encKey *ecdsa.PrivateKey, encKID string) (Decrypter, error)
NewECDHDecrypter builds the keys.Decrypter FAPIgo uses to unwrap the JWE content-encryption key of the id_token and /userinfo response. The encryption key stays in process as an in-memory ECDH backend; FAPIgo owns the ECDH-ES Concat-KDF + RFC-3394 key-unwrap. One decrypter serves both the id_token and the /userinfo response (keys.IDTokenDecryption + keys.UserInfoDecryption).
The private key never leaves this process. A KMS/HSM deployment would instead implement keys.ECDHAgreer / keys.KeyDecrypter over its own backend and inject the result via Dependencies.Decryption.
func NewRotatingDecrypter ¶ added in v0.8.0
func NewRotatingDecrypter(ctx context.Context, current ECDHAgreer, others ...DecryptionKey) (Decrypter, error)
NewRotatingDecrypter is NewAgreerDecrypter over several encryption keys, for an encryption-key rotation: it decrypts with whichever key a token's JWE "kid" names (current when the token names none), and Client.PublicJWKS publishes current and every key in others not marked DecryptOnly.
type DecryptionKey ¶ added in v0.8.0
type DecryptionKey struct {
Key *ecdsa.PrivateKey
KID string
Agreer ECDHAgreer
// DecryptOnly keeps the key for decryption but leaves it out of the
// JWKS: the outgoing key, once it's no longer published, until Singpass
// has re-fetched the JWKS and stopped encrypting to it.
DecryptOnly bool
}
DecryptionKey is an encryption key the client holds alongside its current one during a rotation: published in the JWKS and used to decrypt the tokens encrypted to it, selected by the JWE "kid". Set Key and KID for an in-memory key, or Agreer for an HSM/KMS key (its kid comes from the agreer). See docs/production.md.
type DeniedError ¶
DeniedError is returned by Complete when the authorization server (or the user) declined the request, as opposed to a protocol or transport failure.
func (*DeniedError) Error ¶
func (e *DeniedError) Error() string
type Dependencies ¶
type Dependencies struct {
// Keys performs this client's signing operations (client assertion + DPoP
// proof). Required. See NewKeyManager.
Keys KeyManager
// Decryption recovers the content-encryption key of the encrypted id_token
// (and /userinfo response). Required. See NewECDHDecrypter.
Decryption Decrypter
// KeyCustody declares how the private keys behind Keys and Decryption are
// held. AssuranceProduction requires Durable: the keys survive a restart
// (loaded from a file or secret store, or held in an HSM or KMS), as they
// must anyway once Singpass has your JWKS. It applies to the Keys and
// Decryption this package builds (the product constructors, NewKeyManager,
// NewECDHDecrypter, …); one built with FAPIgo directly declares its own
// custody with keys.DeclareCustody.
KeyCustody KeyCustody
// Sessions persists in-progress authorization-flow state. Nil installs
// NewMemorySessionStore(0): per-process and non-durable, but expired
// sessions are dropped and pending logins are capped, so abandoned logins
// cannot grow memory without bound. Fine for a single instance /
// development; refused under AssuranceProduction.
Sessions SessionStore
// Assurance gates how strict FAPIgo's dependency validation is. Zero means
// AssuranceDevelopment (permits the in-memory Sessions default).
// AssuranceProduction requires a Sessions store declaring StoreAssurance
// (Durable + AtomicConsume), so the in-memory default is refused.
Assurance AssuranceLevel
// HTTPClient is the base client for discovery, JWKS, PAR, token and
// /userinfo calls. Nil builds one with fapihttp.NewClient, whose
// transport refuses private and loopback addresses when it dials, with
// AllowLoopbackHTTP and AllowedPrivateHosts as its exceptions and
// HTTPTimeout as its Timeout. Under AssuranceProduction, a client you
// supply must come from fapihttp.NewClient too (copying it to change
// Timeout is fine), with its transport unchanged: for mutual TLS or a
// private CA, set fapihttp.TransportConfig's Certificates or RootCAs.
HTTPClient *http.Client
// HTTPTimeout bounds each outbound call. Zero means defaultHTTPTimeout. It
// is also folded into the default Limits (below) and the default HTTPClient.
HTTPTimeout time.Duration
// Clock supplies the current time. Nil means client.SystemClock{}.
Clock Clock
// Random is the randomness source for state/nonce/PKCE. Nil means
// crypto/rand.Reader, which AssuranceProduction requires.
Random io.Reader
// BeginLoginRetries is how many times BeginLogin retries a pushed
// authorization request that failed temporarily (see IsTemporary), with
// exponential backoff from 250ms, within the request's context. Zero
// means no retries; Singpass allows up to 3.
BeginLoginRetries int
// Limits bounds token lifetimes and JOSE/HTTP sizes. Nil means
// RecommendedLimits(HTTPTimeout).
Limits *Limits
// Algorithms overrides the FAPI algorithm suite. Nil selects the
// Singpass/Corppass suite (ES256; id_token ECDH-ES+A256KW / A256CBC-HS512;
// and, when Options.FetchUserInfo is set, /userinfo A256GCM). When you set
// this, it is used verbatim and the FetchUserInfo augmentation is skipped —
// declare the /userinfo algorithms yourself.
Algorithms *Algorithms
// AllowLoopbackHTTP permits http:// issuer and endpoint URLs on a loopback
// host — localhost, a name under .localhost, 127.0.0.0/8 or ::1 — for a
// local fake authorization server such as singpasstest's. Any other name
// stays refused even if it resolves to a loopback address, so a URL
// taken from a response can't reach services on this machine.
// Development only: New refuses it together with AssuranceProduction.
AllowLoopbackHTTP bool
// AllowedPrivateHosts lists host names the issuer's discovery document
// and JWKS may be fetched from although they resolve to a private
// address (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 or fc00::/7): a
// fake authorization server reached by its service name on a Docker
// Compose network, such as singpass-fake-server's over HTTPS. Names
// match exactly; any other host resolving to a private address stays
// refused, so a URL taken from a response can't reach the internal
// network. Development only: New refuses it together with
// AssuranceProduction.
AllowedPrivateHosts []string
// Debug, when true, logs outbound PAR/token/userinfo requests and responses
// (method, URL, form body, sizes) via Logger. The request dump includes the
// client_assertion, the authorization code and the PKCE verifier, so New
// refuses it under AssuranceProduction: enable it only against staging.
Debug bool
// Logger receives Debug output and internal warnings. Nil means
// slog.Default().
Logger *slog.Logger
}
Dependencies are a Client's injected collaborators. Only Keys and Decryption are required; every other field's zero value selects a sensible staging default, so the simple path is Dependencies{Keys: km, Decryption: dec}. A production deployment sets the fields it needs to harden — most importantly a durable Sessions store together with Assurance: AssuranceProduction.
type ECDHAgreer ¶ added in v0.2.0
type ECDHAgreer = keys.ECDHAgreer
ECDHAgreer performs the ECDH step of decryption without exposing the private key — the seam for an HSM/KMS-held encryption key (LoginOptions.EncryptionAgreer and friends, NewAgreerDecrypter).
type Environment ¶ added in v0.3.0
type Environment int
Environment selects Singpass/Corppass staging or production for the product constructors (NewLogin, NewMyinfo, NewMyinfoBusiness).
const ( // Staging is the default: the staging issuers, and development assurance // unless Dependencies.Assurance says otherwise. Staging Environment = iota // Production selects the production issuers and, when // Dependencies.Assurance is unset, AssuranceProduction — so the in-memory // session store is refused and a durable one must be supplied. Production )
func (Environment) String ¶ added in v0.3.0
func (e Environment) String() string
String returns "staging" or "production".
type Identity ¶
type Identity struct {
// App is the relying party that produced this identity ("login" / "myinfo").
App string
// Subject is the "sub" — the identifier for this person, as validated from
// the id_token (never an unverified claim).
Subject string
// Claims is the full decoded id_token payload, for display. It has already
// been signature-, issuer-, audience- and nonce-validated by FAPIgo before
// we ever decode it here.
Claims map[string]any
// Scope granted by the authorization server. When the token response omits
// the optional "scope" (RFC 6749 §5.1 permits this when the granted scope
// equals the requested scope — as Singpass Myinfo does), this falls back to
// the requested scope, which under that rule is the granted scope.
Scope string
// IDTokenIssuedAt and IDTokenExpiry are the validated id_token's "iat" and
// "exp" (FAPIgo has already enforced both against the clock). Captured so
// the caller can report the token's exact lifetime (exp − iat).
IDTokenIssuedAt time.Time
IDTokenExpiry time.Time
// Myinfo is the envelope-aware view of the /userinfo person / organisation
// data (decrypted and inner-JWS-verified). Nil for the Login app, which
// makes no userinfo call; non-nil for Myinfo / Myinfo Business, with the
// blocks the response carried populated (see myinfo.Response).
Myinfo *myinfo.Response
}
Identity is the authenticated result of a completed login.
Example ¶
Identity's accessors read the validated id_token claims without any map-casting.
package main
import (
"fmt"
"time"
singpass "github.com/osanderson/singpass-client-go"
)
func main() {
id := &singpass.Identity{
Subject: "a9865837-7bd7-46ac-bef4-42a76a946424",
Claims: map[string]any{
"iss": "https://stg-id.singpass.gov.sg/fapi",
"aud": "your-client-id",
"amr": []any{"pwd", "otp-sms"},
"acr": "urn:singpass:authentication:loa:2",
"sub_attributes": map[string]any{ // released by the user.identity scope
"account_type": "standard", "identity_number": "S1234567G", "identity_coi": "SG",
},
},
IDTokenIssuedAt: time.Unix(1_700_000_000, 0),
IDTokenExpiry: time.Unix(1_700_000_600, 0),
}
fmt.Println(id.Issuer())
fmt.Println(id.Audience())
fmt.Println(id.AuthMethods())
fmt.Println("LOA", id.AssuranceLevel())
fmt.Println(id.SubjectAttributes().IdentityNumber, id.SubjectAttributes().IdentityCOI)
fmt.Println(id.IDTokenExpiry.Sub(id.IDTokenIssuedAt))
}
Output: https://stg-id.singpass.gov.sg/fapi [your-client-id] [pwd otp-sms] LOA 2 S1234567G SG 10m0s
func (*Identity) ActingParty ¶
func (id *Identity) ActingParty() *ActingParty
ActingParty returns the "act" actor claim, or nil when the token carries none (personal Login / Myinfo, where the subject is the person themselves).
func (*Identity) AssuranceContext ¶
AssuranceContext returns the raw "acr" authentication-context-class the OP asserted (e.g. a "…:loa:2" string). Empty when absent. Use AssuranceLevel for the parsed Level of Assurance.
func (*Identity) AssuranceLevel ¶
AssuranceLevel extracts the Level of Assurance from a Singpass/Corppass acr of the form "…:loa:N" — returning the value after "loa:" (e.g. "2"). Empty when acr is absent or not in that form.
func (*Identity) Audience ¶
Audience returns the id_token "aud" — the client_id(s) the token was issued to. OIDC permits aud to be a single string or an array of strings; this normalises both to a slice (and tolerates FAPIgo's AsMap handing back a native []string). Empty values are dropped; nil when the claim is absent.
func (*Identity) AuthMethods ¶
AuthMethods returns the "amr" authentication-methods references (how the user authenticated), or nil when absent. Like Audience it accepts both a []string (as AsMap yields) and a JSON []any.
func (*Identity) Issuer ¶
Issuer returns the id_token "iss" — the OP that issued the token (the FAPI issuer, already validated). Empty when absent.
func (*Identity) SubjectAttributes ¶
func (id *Identity) SubjectAttributes() SubjectAttributes
SubjectAttributes returns the "sub_attributes" claim about the subject: a Singpass person's identity details released by the granted scopes, or a Corppass entity's details. The zero value (Present false) when the claim is absent.
func (*Identity) SubjectType ¶
SubjectType returns the Singpass "sub_type" — "user" for a person, "entity" for an organisation login (Corppass Myinfo Business). Empty when the claim is absent.
type KeyCustody ¶ added in v0.9.0
type KeyCustody = keys.KeyCustody
KeyCustody declares how the client's private keys are held; see Dependencies.KeyCustody.
type KeyManager ¶ added in v0.2.0
type KeyManager = keys.KeyManager
KeyManager signs client assertions and DPoP proofs: Dependencies.Keys. Build one with NewKeyManager.
func NewKeyManager ¶
func NewKeyManager(sig crypto.Signer, sigKID string) (KeyManager, error)
NewKeyManager builds the keys.KeyManager FAPIgo uses for the two signing operations a relying party performs:
- keys.ClientAuthentication — signs the private_key_jwt client assertion with the persistent EC key whose public half is registered with the authorization server under "use":"sig".
- keys.DPoPProofSigning — signs DPoP proofs (RFC 9449) with an EC key generated here at startup. It is never registered, but Singpass binds each authorization code to the DPoP key the login started with, so a callback that reaches another instance, or this one after a restart, fails with invalid_dpop_proof. That suits one long-lived instance in development; AssuranceProduction refuses it. Use NewKeyManagerWithDPoP to give every instance the same DPoP key.
sig is any crypto.Signer over an ES256 / P-256 key: a plain *ecdsa.PrivateKey held in memory, or an HSM/KMS-backed signer. FAPIgo's keys.NewKeyManagerFromSigners adapts it directly — it routes the pre-hashed digest, selects crypto.SignerOpts, and validates the ES256 / P-256 curve requirement at construction — so there is no signing glue here.
The id_token encryption key is NOT handled here — FAPIgo never signs with it. It is used only for JWE decryption, driven through the separate keys.Decrypter (see NewECDHDecrypter), which is why it lives outside the KeyManager contract.
func NewKeyManagerWithDPoP ¶ added in v0.13.0
func NewKeyManagerWithDPoP(sig crypto.Signer, sigKID string, dpop crypto.Signer, published ...PublishedKey) (KeyManager, error)
NewKeyManagerWithDPoP is NewRotatingKeyManager with dpop, an ES256 / P-256 key, as the DPoP key in place of one generated at startup. Give every instance of the app the same dpop, loaded like the signing key (from a file or secret store, or an HSM/KMS signer), so a login can finish on whichever instance the callback reaches, and survive a restart: Singpass binds each authorization code to the DPoP key the login started with. The DPoP key is never published or registered.
func NewRotatingKeyManager ¶ added in v0.8.0
func NewRotatingKeyManager(sig crypto.Signer, sigKID string, published ...PublishedKey) (KeyManager, error)
NewRotatingKeyManager is NewKeyManager plus signing keys that are published in the JWKS but never used to sign, for a signing-key rotation.
type Limits ¶ added in v0.2.0
Limits bounds token lifetimes, sizes and timeouts: Dependencies.Limits. See RecommendedLimits.
func RecommendedLimits ¶
RecommendedLimits starts from FAPIgo's conservative defaults and applies the three a Singpass/Corppass relying party genuinely needs to override:
- MaxIDTokenLifetime — RecommendedLimits leaves this zero on purpose (it depends on the specific issuer), and client.New rejects zero. Singpass issues 10-minute id_tokens; Corppass Myinfo Business has issued longer ones (10 minutes on staging as of 2026-09-26), so allow up to an hour as headroom.
- HTTPTimeout — the per-call bound to the authorization server.
- MaxJOSECompactBytes — RecommendedLimits defaults this to 16 KiB, which a broad-scope Myinfo /userinfo response overshoots: requesting the full person-data scope set yields an ~23 KiB response JWE (and an even larger one for a person carrying many vehicle/children/CPF records), and the cap bounds both the outer JWE and the decrypted inner JWS. 256 KiB gives ample headroom while staying well under MaxHTTPResponseBytes (1 MiB). Only the id_token and /userinfo are affected; fixed-shape artifacts (DPoP proofs, client assertions) keep the library's 16 KiB default.
Everything else (client-assertion lifetime, session lifetime, clock skew, HTTP response-size cap) takes the library's recommended value. A caller that needs different bounds sets Dependencies.Limits to its own client.Limits.
type LoginContext ¶ added in v0.10.0
LoginContext describes, for Singpass Login, the transaction a user is authenticating for: Type is the authentication_context_type (one of the values Singpass defines, used against fraud) and Message the optional authentication_context_message shown to the user — up to 100 printable ASCII characters, excluding < > \ and `. Either left empty falls back to the client's LoginOptions.
type LoginOptions ¶
type LoginOptions struct {
Name string // app slug for logging/Identity; defaults to "login"
Environment Environment // Staging (default) or Production: picks the issuer and assurance
Issuer string // FAPI issuer; empty means Environment's Singpass issuer
ClientID string // client_id issued by the authorization server
RedirectURI string // must match what is registered with the server
Scopes []string // must include "openid"
// AuthContextType is the Login authentication_context_type; defaults to
// DefaultAuthContextType. AuthContextMessage is the optional
// authentication_context_message shown to the user while they
// authenticate: up to 100 printable ASCII characters, excluding < > \ and
// `. Both describe the transaction and can be set per login with
// Client.BeginLoginWith.
AuthContextType string
AuthContextMessage string
// AppClaimedHTTPS marks RedirectURI as a mobile app's App Link /
// Universal Link (redirect_uri_https_type=app_claimed_https), for a login
// started from a native app. AppLaunchURL is the optional iOS App Link
// that returns the user to your app from the Singpass app.
AppClaimedHTTPS bool
AppLaunchURL string
// AcrValues is the optional requested level of assurance; "" to omit
// (Singpass rejects it unless the client is whitelisted for it).
AcrValues string
// Key material. Used to build Dependencies.Keys / Dependencies.Decryption
// when the caller leaves those nil; ignored when they are set (an HSM/KMS
// caller injects its own).
SigningKey crypto.Signer // ES256 / P-256 client-assertion signer
SigningKID string // kid of the registered signing key
// DPoPKey is the ES256 / P-256 key DPoP proofs are signed with, the same
// on every instance (see NewKeyManagerWithDPoP). Nil generates one at
// startup, which only suits a single instance in development:
// AssuranceProduction requires it.
DPoPKey crypto.Signer
EncryptionKey *ecdsa.PrivateKey // id_token decryption key (in-memory)
EncryptionKID string // kid of EncryptionKey; ignored when EncryptionAgreer is set
// EncryptionAgreer is an optional HSM/KMS-backed ECDH agreer for id_token
// decryption, used in place of the in-memory EncryptionKey. When set it wins
// over EncryptionKey (the encryption private key then never enters this
// process), and the kid comes from the agreer itself — EncryptionKID is not
// used. Ignored when Dependencies.Decryption is supplied. See
// NewAgreerDecrypter.
EncryptionAgreer ECDHAgreer
// AdditionalSigningKeys and AdditionalEncryptionKeys are the extra keys of
// a key rotation (see docs/production.md): signing keys published in the
// JWKS but not used to sign, and encryption keys decrypted with and —
// unless DecryptOnly — published. They need the keys above: with
// Dependencies.Keys or Dependencies.Decryption supplied, use
// NewRotatingKeyManager / NewRotatingDecrypter there instead.
AdditionalSigningKeys []PublishedKey
AdditionalEncryptionKeys []DecryptionKey
}
LoginOptions configures a Singpass (or Corppass) Login relying party: authentication only, no /userinfo call. authentication_context_type is mandatory for Login and defaults to DefaultAuthContextType.
type MyinfoBusinessOptions ¶
type MyinfoBusinessOptions struct {
Name string // app slug for logging/Identity; defaults to "myinfobiz"
Environment Environment // Staging (default) or Production: picks the issuer and assurance
Issuer string // FAPI issuer; empty means Environment's Corppass issuer
ClientID string // client_id issued by Corppass
RedirectURI string // must match what is registered with the server
Scopes []string // entity.* / user.* / corppass.* scopes; must include "openid"
AcrValues string // optional requested level of assurance; "" to omit
SigningKey crypto.Signer // ES256 / P-256 client-assertion signer
SigningKID string // kid of the registered signing key
// DPoPKey is the ES256 / P-256 key DPoP proofs are signed with, the same
// on every instance (see NewKeyManagerWithDPoP). Nil generates one at
// startup, which only suits a single instance in development:
// AssuranceProduction requires it.
DPoPKey crypto.Signer
EncryptionKey *ecdsa.PrivateKey // id_token / userinfo decryption key (in-memory)
EncryptionKID string // kid of EncryptionKey; ignored when EncryptionAgreer is set
// EncryptionAgreer is an optional HSM/KMS-backed ECDH agreer for id_token /
// userinfo decryption, used in place of the in-memory EncryptionKey. When set
// it wins over EncryptionKey (the encryption private key then never enters
// this process), and the kid comes from the agreer itself — EncryptionKID is
// not used. Ignored when Dependencies.Decryption is supplied. See
// NewAgreerDecrypter.
EncryptionAgreer ECDHAgreer
// AdditionalSigningKeys and AdditionalEncryptionKeys are the extra keys of
// a key rotation (see docs/production.md): signing keys published in the
// JWKS but not used to sign, and encryption keys decrypted with and —
// unless DecryptOnly — published. They need the keys above: with
// Dependencies.Keys or Dependencies.Decryption supplied, use
// NewRotatingKeyManager / NewRotatingDecrypter there instead.
AdditionalSigningKeys []PublishedKey
AdditionalEncryptionKeys []DecryptionKey
}
MyinfoBusinessOptions configures a Corppass Myinfo Business relying party: the corporate counterpart of Myinfo, on the separate Corppass FAPI 2.0 authorization server. Same protocol as Myinfo, with the Corppass issuer default.
type MyinfoOptions ¶
type MyinfoOptions struct {
Name string // app slug for logging/Identity; defaults to "myinfo"
Environment Environment // Staging (default) or Production: picks the issuer and assurance
Issuer string // FAPI issuer; empty means Environment's Singpass issuer
ClientID string // client_id issued by the authorization server
RedirectURI string // must match what is registered with the server
Scopes []string // person-data scopes; must include "openid"
AcrValues string // optional requested level of assurance; "" to omit
// AppClaimedHTTPS marks RedirectURI as a mobile app's App Link /
// Universal Link (redirect_uri_https_type=app_claimed_https), for a login
// started from a native app. AppLaunchURL is the optional iOS App Link
// that returns the user to your app from the Singpass app.
AppClaimedHTTPS bool
AppLaunchURL string
SigningKey crypto.Signer // ES256 / P-256 client-assertion signer
SigningKID string // kid of the registered signing key
// DPoPKey is the ES256 / P-256 key DPoP proofs are signed with, the same
// on every instance (see NewKeyManagerWithDPoP). Nil generates one at
// startup, which only suits a single instance in development:
// AssuranceProduction requires it.
DPoPKey crypto.Signer
EncryptionKey *ecdsa.PrivateKey // id_token / userinfo decryption key (in-memory)
EncryptionKID string // kid of EncryptionKey; ignored when EncryptionAgreer is set
// EncryptionAgreer is an optional HSM/KMS-backed ECDH agreer for id_token /
// userinfo decryption, used in place of the in-memory EncryptionKey. When set
// it wins over EncryptionKey (the encryption private key then never enters
// this process), and the kid comes from the agreer itself — EncryptionKID is
// not used. Ignored when Dependencies.Decryption is supplied. See
// NewAgreerDecrypter.
EncryptionAgreer ECDHAgreer
// AdditionalSigningKeys and AdditionalEncryptionKeys are the extra keys of
// a key rotation (see docs/production.md): signing keys published in the
// JWKS but not used to sign, and encryption keys decrypted with and —
// unless DecryptOnly — published. They need the keys above: with
// Dependencies.Keys or Dependencies.Decryption supplied, use
// NewRotatingKeyManager / NewRotatingDecrypter there instead.
AdditionalSigningKeys []PublishedKey
AdditionalEncryptionKeys []DecryptionKey
}
MyinfoOptions configures a Singpass Myinfo relying party: authentication plus person data retrieved from the DPoP-protected /userinfo endpoint. authentication_context_type is not sent (Singpass rejects it on Myinfo).
type Options ¶
type Options struct {
Name string // app slug ("login" / "myinfo"), for logging and Identity
Issuer string // FAPI issuer, e.g. https://stg-id.singpass.gov.sg/fapi
ClientID string // client_id issued by the authorization server
RedirectURI string // must match what is registered with the server
Scopes []string // includes "openid"
AuthContextType string // authentication_context_type (Login apps only; rejected on Myinfo)
// AuthContextMessage is the optional authentication_context_message
// (Login apps only): up to 100 printable ASCII characters, excluding
// < > \ and `, shown to the user while they authenticate.
AuthContextMessage string
// AppClaimedHTTPS sends redirect_uri_https_type=app_claimed_https, for a
// RedirectURI that is a mobile app's App Link / Universal Link.
AppClaimedHTTPS bool
// AppLaunchURL is the optional app_launch_url: the iOS App Link that
// returns the user to your app after authenticating in the Singpass app,
// for a journey that starts and ends in an iOS app. Must be https.
AppLaunchURL string
AcrValues string // optional requested level of assurance; "" to omit
FetchUserInfo bool // call the FAPI /userinfo endpoint after token exchange (Myinfo)
}
Options is the protocol configuration for a Client: the behaviour knobs that describe how this relying party talks to its authorization server. The injectable collaborators (keys, session store, HTTP client, …) live in Dependencies. The per-product constructors in products.go fill most of this in for you.
type PublishedKey ¶ added in v0.8.0
PublishedKey is a public key and its kid, as published in the client's JWKS. In AdditionalSigningKeys it is a signing key published alongside the current one but never used to sign: during a rotation, the incoming key before the switch. See docs/production.md.
type ServerErrorResponse ¶ added in v0.9.0
type ServerErrorResponse = client.ServerErrorResponse
ServerErrorResponse is the error response Singpass or Corppass sent: the OAuth error code (e.g. "invalid_client"), its description and URI, and the HTTP status. See ServerError.
func ServerError ¶ added in v0.9.0
func ServerError(err error) (ServerErrorResponse, bool)
ServerError returns the error response from Singpass or Corppass behind err — from BeginLogin's pushed authorization request, or Complete's token or /userinfo call — or false when err didn't come from one (a transport failure, a validation failure, or an error from this package). The description is the server's own text: log it, don't show it to users.
type SessionStore ¶ added in v0.2.0
type SessionStore = storage.SessionStore
SessionStore holds in-flight authorization state (state, nonce, PKCE verifier) between BeginLogin and Complete: Dependencies.Sessions. Production needs a durable implementation that also implements StoreAssurance; verify one with FAPIgo's storage.TestSessionStoreContract. Return (or wrap) ErrLoginExpired from Consume for an unknown, already-used or expired state.
func NewMemorySessionStore ¶
func NewMemorySessionStore(maxPending int) SessionStore
NewMemorySessionStore returns an in-memory storage.SessionStore for FAPIgo's in-flight authorization state (the state, and the record holding the nonce, PKCE verifier and the rest) between BeginLogin and Complete. It is the default when Dependencies.Sessions is nil.
Unlike FAPIgo's memstore, which keeps a session until its callback arrives, it drops a session once its ExpiresAt passes, so logins that are started and never finished do not accumulate. It also refuses new sessions beyond maxPending (0 means DefaultMaxPendingLogins) with ErrTooManyPendingLogins, so memory stays bounded however fast the login route is hit.
It is still per-process and non-durable, declares no storage.StoreAssurance, and so — like memstore — is refused under AssuranceProduction.
type StoreAssurance ¶ added in v0.2.0
type StoreAssurance = storage.StoreAssurance
StoreAssurance is how a SessionStore declares it is durable and consumes atomically, which AssuranceProduction requires.
type SubjectAttributes ¶ added in v0.6.0
type SubjectAttributes struct {
AccountType string // account_type: "standard" (citizen / PR) or "foreign"
IdentityNumber string // identity_number: NRIC, FIN or foreign ID number
IdentityCOI string // identity_coi: the ID's country of issuance, e.g. "SG"
Name string // name
Email string // email
MobileNo string // mobileno (absent for foreign account holders)
EntityType string // entity_type: "UEN", "NON-UEN" or "GSTN"
EntityRegNumber string // entity_reg_number, e.g. the UEN
EntityCOI string // entity_coi: country of incorporation
EntityName string // entity_name
EntityUENStatus string // entity_uen_status: "Registered", "Deregistered", "Withdrawn"
// Raw is the claim as sent, including any member not modelled above; nil
// when the token carries no sub_attributes.
Raw map[string]any
}
SubjectAttributes are the "sub_attributes" descriptor claims about a subject, with the members Singpass and Corppass define as typed fields. Which fields are set depends on the subject and the granted scopes; unset fields are "".
For a person (Singpass Login / Myinfo, or the acting person in a Corppass login) Singpass releases each item with its scope: user.identity gives AccountType, IdentityNumber and IdentityCOI; the name, email and mobileno scopes give Name, Email and MobileNo. For a Corppass entity the Entity* fields describe the organisation.
func (SubjectAttributes) Present ¶ added in v0.6.0
func (a SubjectAttributes) Present() bool
Present reports whether the token carried the sub_attributes claim.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
singpass-fake-server
command
Command singpass-fake-server runs fake Singpass and Corppass FAPI 2.0 authorization servers — the singpasstest package — as a standalone process, so an app in any language can log in against them locally, without onboarding or real accounts:
|
Command singpass-fake-server runs fake Singpass and Corppass FAPI 2.0 authorization servers — the singpasstest package — as a standalone process, so an app in any language can log in against them locally, without onboarding or real accounts: |
|
singpass-keygen
command
Command singpass-keygen creates the two EC P-256 keys a Singpass or Corppass client registers — a signing key (private_key_jwt client assertions and DPoP) and an encryption key (id_token and /userinfo decryption) — and prints the public JWKS to register during onboarding.
|
Command singpass-keygen creates the two EC P-256 keys a Singpass or Corppass client registers — a signing key (private_key_jwt client assertions and DPoP) and an encryption key (id_token and /userinfo decryption) — and prints the public JWKS to register during onboarding. |
|
examples
|
|
|
minimal
command
Command minimal is the smallest useful Singpass Login integration: one client, the web helper for the login/callback/logout routes, and a page that greets the signed-in user.
|
Command minimal is the smallest useful Singpass Login integration: one client, the web helper for the login/callback/logout routes, and a page that greets the signed-in user. |
|
Package keyfile holds small helpers for generating, marshaling, and loading the EC P-256 (ES256 / ECDH-ES) key pairs a relying party registers with the authorization server.
|
Package keyfile holds small helpers for generating, marshaling, and loading the EC P-256 (ES256 / ECDH-ES) key pairs a relying party registers with the authorization server. |
|
Package myinfo is the data model for a Myinfo / Myinfo Business /userinfo response.
|
Package myinfo is the data model for a Myinfo / Myinfo Business /userinfo response. |
|
Package singpasstest runs a fake Singpass or Corppass FAPI 2.0 authorization server in-process, so an integration can be tested — or demoed — without onboarding, network access or real accounts.
|
Package singpasstest runs a fake Singpass or Corppass FAPI 2.0 authorization server in-process, so an integration can be tested — or demoed — without onboarding, network access or real accounts. |
|
Package sqlstore is a durable, SQL-backed session store for production: it implements both the protocol session store (singpass.SessionStore, for Dependencies.Sessions) and the web helper's login-session store (web.LoginSessionStore, for web.Config.LoginSessions).
|
Package sqlstore is a durable, SQL-backed session store for production: it implements both the protocol session store (singpass.SessionStore, for Dependencies.Sessions) and the web helper's login-session store (web.LoginSessionStore, for web.Config.LoginSessions). |
|
Package web is an optional HTTP helper for building a relying-party web app on top of the singpass package.
|
Package web is an optional HTTP helper for building a relying-party web app on top of the singpass package. |