Help us shape the future of Go! Take the 2026 Go Developer Survey

singpass

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 25, 2026 License: MIT Imports: 23 Imported by: 0

README

singpass-client-go — a Go relying-party library for Singpass Login, Myinfo & Myinfo Business

github.com/osanderson/singpass-client-go is a small Go library that lets a relying party (RP) integrate Singpass Login, Myinfo, and Myinfo Business (Corppass) over the FAPI 2.0 Security Profile without rediscovering the Singpass/Corppass-specific details. It wraps the FAPIgo relying-party engine (v0.32.0), which drives the whole protocol; this library supplies the pieces FAPIgo leaves to the embedder and encodes the product-specific choices.

Singpass Login and Myinfo are the same FAPI 2.0 authorization server (id.singpass.gov.sg); Myinfo Business is a separate authorization server run by Corppass (id.corppass.gov.sg). Each product is onboarded as its own client — its own client_id and its own signing/encryption key pair.

No Singpass-specific protocol shims. FAPIgo sends the full PAR request (client_id, the plain-string authentication_context_type / acr_values parameters, and a DPoP proof binding the code to the DPoP key), decrypts the encrypted (JWE) id_token and exposes its validated claims, and makes the DPoP-protected /userinfo call. This library supplies only key material and small hooks — a keys.KeyManager (signs client assertions + DPoP proofs), a keys.Decrypter assembled from FAPIgo's own helpers, and a storage.SessionStore. There is no JOSE code of its own.

Install

go get github.com/osanderson/singpass-client-go

Requires Go 1.26.6+ (the minimum FAPIgo requires).

Packages

Import Role
github.com/osanderson/singpass-client-go (package singpass) Protocol core. Client, Options, Dependencies, Identity, DeniedError, and the product constructors NewLogin / NewMyinfo / NewMyinfoBusiness.
github.com/osanderson/singpass-client-go/myinfo Myinfo data model. Response, Data, Field, Source, Kind, Authorisation — the envelope-aware view Identity.Myinfo returns. Standard library only; myinfo.Parse works on any decoded /userinfo map.
github.com/osanderson/singpass-client-go/web Optional HTTP helper. Per-app login / callback / logout / JWKS handlers, an app-session store, and cookie handling. Emits no HTML — you render via callbacks.
github.com/osanderson/singpass-client-go/keyfile PEM key helpers. Generate / marshal / load EC P-256 keys. No FAPIgo dependency, so it is safe to use from key-generation tooling.

Quick start

The per-product constructors take just key material and a client_id; every other dependency defaults to a staging-appropriate value.

import (
    "context"

    singpass "github.com/osanderson/singpass-client-go"
    "github.com/osanderson/singpass-client-go/keyfile"
)

// Load the EC P-256 keys you registered with Singpass (see "Generating keys").
sigKey, _ := keyfile.LoadECPrivateKey("keys/login/sig.pem")
encKey, _ := keyfile.LoadECPrivateKey("keys/login/enc.pem")

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, // crypto.Signer — an HSM/KMS signer works too
    SigningKID:    "login-sig-1",
    EncryptionKey: encKey, // or EncryptionAgreer for an HSM/KMS-backed key (see below)
    EncryptionKID: "login-enc-1",
    // Issuer defaults to singpass.StagingSingpassIssuer; AuthContextType defaults to
    // singpass.DefaultAuthContextType. Pass a production issuer for live.
}, singpass.Dependencies{})

Then drive the flow:

// 1. Start login: run PAR, get the redirect URL + an opaque state handle.
redirectURL, state, err := client.BeginLogin(ctx)
// … set `state` as a short-lived cookie, redirect the browser to redirectURL …

// 2. On the callback, validate + exchange the code.
id, err := client.Complete(ctx, r.URL.RawQuery)
var denied *singpass.DeniedError
if errors.As(err, &denied) {
    // user cancelled / server denied — denied.Code, denied.Description
}
// id.Subject, id.Claims, id.Scope; id.Myinfo for Myinfo / Myinfo Business.

Beyond Subject / Scope, Identity has typed accessors over the validated id_token claims, so you needn't re-parse the raw Claims map or know the Singpass/Corppass claim shapes:

id.Issuer()            // "iss" — the validated FAPI issuer
id.Audience()          // "aud" as a []string (OIDC allows string or array)
id.AuthMethods()       // "amr" — how the user authenticated
id.SubjectType()       // "user" (person) vs "entity" (Corppass organisation login)
id.AssuranceContext()  // raw "acr"; id.AssuranceLevel() → the "…:loa:N" level ("2")
id.ActingParty()       // Corppass "act": the person acting for the entity (nil otherwise)
id.SubjectAttributes() // "sub_attributes" — entity name / reg number / status (Corppass)

NewMyinfo and NewMyinfoBusiness are the same shape; they preset FetchUserInfo so Complete also calls the DPoP-protected /userinfo endpoint and populates Identity.Myinfo (see "Parsing person data" below). NewMyinfoBusiness additionally defaults to the Corppass issuer and enables the Corppass /userinfo sub == client_id tolerance.

For a non-standard product or full control, use the lower-level singpass.New(ctx, singpass.Options{...}, singpass.Dependencies{...}) directly.

Options vs Dependencies

singpass.New splits configuration in two:

  • Options — protocol behaviour: Issuer, ClientID, RedirectURI, Scopes, AuthContextType, AcrValues, FetchUserInfo, TolerateUserInfoSubjectClientID. The product constructors fill most of this in.

  • Dependencies — injected collaborators. Only Keys and Decryption are required (the product constructors build them from your key material when you leave them nil). Every other field's zero value selects a staging default, so singpass.Dependencies{} reproduces a working staging client:

    Field Zero-value default Set it for production
    Sessions storage.SessionStore singpass.NewMemorySessionStore(0) (in-memory, non-durable; expires abandoned logins, caps pending ones) a durable store (required under AssuranceProduction)
    Assurance client.AssuranceLevel AssuranceDevelopment client.AssuranceProduction
    HTTPClient / HTTPTimeout &http.Client{Timeout: 15s} your own client / timeout
    Clock / Random SystemClock{} / crypto/rand.Reader —
    Limits RecommendedLimits(HTTPTimeout) (1h id_token, 256 KiB JOSE) override if needed
    Algorithms Singpass/Corppass suite override for a non-standard product
    Debug / Logger off / slog.Default() leave Debug off in production

Under AssuranceProduction, FAPIgo's client-side session-store gate rejects the in-memory default — so a real deployment must set both Assurance: client.AssuranceProduction and a Sessions store declaring storage.StoreAssurance (Durable + AtomicConsume), or construction fails fast rather than silently shipping a non-durable store.

Web helper (web)

web turns one or more *singpass.Client into HTTP handlers, each under its own /{name}/* routes, and hands rendering back to you through callbacks (it emits no HTML):

import "github.com/osanderson/singpass-client-go/web"

jwks, _ := client.PublicJWKS(ctx)
handlers := web.New(web.Config{
    Apps: []*web.App{{Name: "login", Title: "Singpass Login", Auth: client, JWKS: jwks}},
    OnAuthenticated: func(w http.ResponseWriter, r *http.Request, app *web.App, id *singpass.Identity) {
        // session cookie is already set; render or redirect
    },
    OnDenied: func(w http.ResponseWriter, r *http.Request, app *web.App, d *singpass.DeniedError) { /* … */ },
    OnError:  func(w http.ResponseWriter, r *http.Request, app *web.App, err error) { /* … */ },
})

mux := handlers.Mux() // or handlers.RegisterRoutes(yourMux)
// Add your own "/" using handlers.CurrentIdentity(r) to choose landing vs profile.

It registers /{name}/login, /{name}/callback, /{name}/logout (POST only, cross-origin requests rejected — render it as a same-origin <form method="post">), and /{name}/jwks.json per app, keeps a defense-in-depth state cookie, stores the authenticated identity in a LoginSessionStore (set via Config.LoginSessions; default NewMemoryLoginSessionStore()), and exposes CookieConfig for names/TTLs/Secure/SameSite (set Cookies.Secure = true behind HTTPS). This application login-session store is deliberately named apart from FAPIgo's protocol session store on Dependencies.Sessions — they are different concepts.

Generating keys

Each client registers two EC P-256 keys: a signing key (private_key_jwt client assertion + DPoP) and an encryption key (id_token / userinfo decryption). Both support an HSM/KMS backend so no private key need enter the process: the signing key is any crypto.Signer (passed as SigningKey), and the encryption key can be a keys.ECDHAgreer (passed as EncryptionAgreer, which wins over the in-memory EncryptionKey) — its AgreeSharedSecret maps to an HSM's CKM_ECDH1_DERIVE or a KMS's DeriveSharedSecret, while FAPIgo still owns the Concat-KDF + key-unwrap. keyfile generates and loads them; singpass.OfflineClientJWKS produces the public JWKS to register during onboarding — before any client_id or discovery document exists — using the same FAPIgo library code the live client's Client.PublicJWKS resolves through, so the offline and online sets are identical (jwks.go). The DPoP key is deliberately not published.

sig, _ := keyfile.GenerateECKey()
enc, _ := keyfile.GenerateECKey()
pemBytes, _ := keyfile.MarshalECPrivateKeyPEM(sig) // write 0600
jwks, _ := singpass.OfflineClientJWKS(ctx, sig, "login-sig-1", enc, "login-enc-1")

The examples/demo module ships a ready-made cmd/keygen doing exactly this for all three products.

What Singpass/Corppass need, and where FAPIgo handles it

FAPIgo drives the generic FAPI 2.0 flow. Singpass differs from that baseline in a few places, all handled inside FAPIgo with small hooks this library supplies:

  1. PAR request parameters (outbound) — sent by FAPIgo, so there is no outbound request rewriting:

    • client_id — top-level PAR parameter (RFC 6749 §4.1.1 / RFC 9126).
    • DPoP binding at PAR — FAPIgo sends a DPoP proof in a DPoP header on the PAR request (RFC 9449 §10.1, client.PARDPoPBindingProof), binding the authorization code to the DPoP key it will use at the token endpoint. Singpass requires this binding up front.
    • authentication_context_type — required for Login apps (APP_AUTHENTICATION_DEFAULT for a standard login) and rejected on Myinfo / Myinfo Business requests. NewLogin sets it; NewMyinfo / NewMyinfoBusiness omit it. It rides as a plain-string FAPIgo extension (extensions.go), emitted as a top-level PAR parameter on the FAPI 2.0 baseline profile.
    • acr_values — via Options.AcrValues, only if set (Singpass rejects it unless your client is whitelisted for it).
  2. Encrypted id_token (inbound) — driven by FAPIgo. Singpass returns a JWE wrapping a JWS (ECDH-ES+A256KW + A256CBC-HS512). This library declares those algorithms and provides a keys.Decrypter built from FAPIgo's keys.NewSingleKeyDecrypter / keys.NewInMemoryECDH (decrypter.go). FAPIgo parses the compact JWE, delegates only the ECDH primitive to that decrypter to recover the content-encryption key, decrypts the payload, checks cty=JWT, and verifies the inner JWS itself. Declaring the algorithms also enables FAPIgo's downgrade protection: a plain (unencrypted) id_token is rejected. The validated claims land on Identity.Claims.

Everything else — PKCE S256, private_key_jwt, the token-endpoint DPoP proof, iss checking (RFC 9207), and the id_token's own signature / iss / aud / nonce / exp validation — is FAPIgo's.

Myinfo and /userinfo

The id_token only proves who logged in; person data lives behind the DPoP-protected /userinfo endpoint. When FetchUserInfo is set, Complete calls FAPIgo's native Client.FetchUserInfo, which: makes the DPoP-bound GET (RFC 9449, with ath), retries once on a 401 + DPoP-Nonce challenge (§9), decrypts the response JWE (ECDH-ES+A256KW / A256GCM — note the content encryption differs from the id_token's A256CBC-HS512) through the same keys.Decrypter (which serves both IDTokenDecryption and UserInfoDecryption), verifies the inner issuer JWS (ES256), and checks the response sub. The validated result is exposed on Identity.Myinfo (see below).

Parsing person data

Myinfo returns every data item inside an envelope: most fields carry a value; coded fields (sex, nationality, residentialstatus, …) carry a code + human desc; each also carries source (1 government-verified, 2 user-provided, 3 not applicable, 4 verified), classification, and lastupdated; an item the source can't provide is flagged "unavailable": true with no value. Addresses and phone numbers are nested objects, and some records are arrays. Myinfo Business additionally groups data into several blocks (entity_info / person_info / corppass_info / auth_info / tp_auth_info), some of which arrive as double-encoded (stringified) JSON.

Identity.Myinfo (a *myinfo.Response, from the myinfo package) is a small envelope-aware accessor over that — no map-casting, no knowledge of the envelope, and robust to Singpass adding catalog fields (unknown fields stay reachable). It unwraps the double-encoding for you and never panics (absent blocks/fields return zero values):

if id.Myinfo != nil {                      // nil for Login (no /userinfo call)
    p := id.Myinfo.Person                  // a Data block; .Entity/.Corppass/.Auth/.TPAuth for Business
    p.Field("name").String()               // "TAN XIAO HUI"        (value, or coded desc, or "")
    p.Field("nationality").Code()          // "SG"
    p.Field("nationality").Desc()          // "SINGAPORE CITIZEN"
    p.Field("email").Available()           // false if {unavailable:true} or empty
    p.Field("name").Source()               // "1"  (raw provenance code)
    p.Field("name").SourceCode()           // myinfo.SourceGovernmentVerified (typed)
    p.Field("name").SourceCode().Authoritative()   // true — govt- or user-verified
    p.Field("name").ClassificationCode().Confidential() // true for "C"
    p.Object("regadd").Field("postal").Value()   // nested object
    p.Object("regadd").SourceCode()        // container-level provenance: grouped datasets
                                           // declare source/classification/lastupdated on the
                                           // container object, not the value leaves inside
    p.Kind("regadd")                       // myinfo.KindObject — envelope shape (Leaf/Object/List/
                                           // Scalar/Absent), for dispatching a generic walk
    p.List("vehicles")                     // []Data — repeated records
    id.Myinfo.Auth.Authorisations()        // []myinfo.Authorisation — Corppass auth_info,
                                           // the bespoke Result_Set/ESrvc_Result/Row
                                           // nesting flattened (also .TPAuth for tp_auth_info)
    id.Myinfo.Blocks()                     // recognised blocks present
    id.Myinfo.Raw()                        // full decoded response — escape hatch
}

examples/demo renders the person data from this accessor as grouped sections — one per block, with repeated-record collections (appointments, shareholders, CPF contribution history, …) shown as tables — and keeps the raw /userinfo JSON alongside in a collapsed panel.

Myinfo Business (Corppass)

Protocol-wise identical to Myinfo — PAR, DPoP, private_key_jwt, PKCE, encrypted id_token, DPoP-protected /userinfo, same algorithms. The differences are configuration, encoded in NewMyinfoBusiness:

  • Different issuer — id.corppass.gov.sg, with no /fapi path suffix (Singpass uses id.singpass.gov.sg/fapi). Defaults to singpass.StagingCorppassIssuer.
  • Scope namespaces — entity.* (organisation), user.* (person), corppass.* (Corppass account); each must be whitelisted on the client.
  • Multiple /userinfo blocks — the response can carry entity_info, person_info, corppass_info, auth_info, tp_auth_info, each a nested object. Identity.Myinfo exposes each one as its own Data block (Person, Entity, Corppass, Auth, TPAuth; see myinfo/myinfo.go) — personal Myinfo fills only Person. Corppass also double-encodes some blocks (and nested objects) as stringified JSON; these are unwrapped recursively, so every block reads as real nested JSON.
  • sub == client_id tolerance — Corppass sets the /userinfo sub to the client_id (an OIDC Core §5.3.2 deviation); NewMyinfoBusiness opts into accepting it.

See docs/corppass-quirks.md and docs/singpass-quirks.md for the full details.

Example app

examples/demo is a runnable web app (its own Go module) that wires all three products through singpass + web. From that directory:

cp .env.example .env      # set SINGPASS_LOGIN_CLIENT_ID / MYINFO_CLIENT_ID / MYINFO_BIZ_CLIENT_ID
go run ./cmd/keygen       # writes keys/<app>/{sig,enc}.pem and prints each public JWKS
set -a; . ./.env; set +a
go run ./cmd/server       # http://localhost:8088

The demo's go.mod points the library at the local tree with a replace directive, so changes to the library are picked up without publishing. (A local go.work covering both modules works too; it is gitignored.)

Deployment

Every push to main runs .github/workflows/deploy.yml: it tests both modules, builds examples/demo/Dockerfile (from the repo root, since the demo replaces the library with ../..), and deploys the image to Cloud Run (service rp-demo, project singpass-demo-rp, asia-southeast1) at https://rp-demo-1090410730433.asia-southeast1.run.app. GitHub authenticates keylessly through Workload Identity Federation, which only accepts this repo on main.

  • Config — examples/demo/deploy/cloudrun.env.yaml is the service's complete non-secret environment; each deploy replaces the service's env vars with it.
  • Keys — each private key is a Secret Manager secret (singpass-demo-<app>-<sig|enc>) mounted at /secrets/<app>-<sig|enc>/key.pem. Only the singpass-demo-runtime service account can read them; the deployer cannot. To rotate, add a new secret version and redeploy.
  • Single instance — pending logins and app sessions are in memory, so the service runs with --max-instances=1; a scale-to-zero or redeploy logs everyone out.
  • Redirect URIs — <APP_BASE_URL>/<app>/callback must be registered with each Singpass / Corppass client alongside the localhost ones. Singpass and Corppass reject redirect URIs containing "singpass", "corppass" or "myinfo" anywhere (host included), which is why the service is rp-demo and the demo's app slugs are login, mi (Myinfo) and mib (Myinfo Business).

The container reads Cloud Run's PORT (APP_ADDR still wins if set), logs JSON for Cloud Logging when LOG_FORMAT=json (set in the image), and shuts down gracefully on SIGTERM.

Notes & caveats

  • Staging by default. The Staging* issuer constants and the Dependencies zero values target Singpass/Corppass staging. For production, pass a production Issuer and set Assurance + a durable Sessions store (see above).
  • Debug dumps secrets. Dependencies.Debug logs outbound PAR/token/userinfo requests including the client_assertion — enable it only against staging.
  • Ephemeral DPoP key. The DPoP proof key is generated per KeyManager and registered nowhere — sender-constraining and correctly non-persistent. It signs the DPoP proof at PAR, the token request and /userinfo, so all three match the token binding.
  • JOSE size cap. FAPIgo bounds the compact JWS/JWE size it will parse. The default RecommendedLimits raises MaxJOSECompactBytes to 256 KiB (limits.go), since a full-scope Myinfo /userinfo response runs well past the 16 KiB baseline; fixed-shape artifacts (DPoP proofs, client assertions) are unaffected.

Contributing

main is protected by a repository ruleset: changes land only through pull requests, squash-merged (linear history, no force pushes or deletion), and require passing checks — commit-lint plus ci.yml (gofmt, go mod tidy, go vet, staticcheck, build, go test -race, govulncheck for both modules; a no-push build of the demo image; actionlint).

Commit messages and PR titles follow Conventional Commits: <type>(<scope>)?!?: <summary>, with type one of build, chore, ci, docs, feat, fix, perf, refactor, revert, style or test, e.g. feat(myinfo): expose container-level source. The rule lives in scripts/check-commit-msg.sh and is enforced in three places:

  • Locally — enable the bundled commit-msg hook once per clone: git config core.hooksPath .githooks.
  • Pull requests — commit-lint.yml checks the PR title and every commit. PRs are squash-merged with the PR title as the commit subject.
  • Pushes to main — the commit-lint job in deploy.yml re-checks the pushed commits (and deploy.yml re-runs ci.yml) before deploying.
Releases

Releases are automated by release-please (release-please.yml). From the Conventional Commits on main it keeps a release PR open that bumps the version and updates CHANGELOG.md; merging it tags vX.Y.Z (the Go module version) and publishes a GitHub release. Before 1.0, feat bumps the minor version, fix the patch, and breaking changes (! / BREAKING CHANGE:) the minor. Changes only under examples/ don't trigger a library release. To force a version, add a Release-As: X.Y.Z footer to a commit.

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.

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.

Index

Constants

View Source
const (
	// StagingSingpassIssuer is the Singpass FAPI 2.0 issuer (Login + Myinfo).
	StagingSingpassIssuer = "https://stg-id.singpass.gov.sg/fapi"
	// StagingCorppassIssuer is the Corppass FAPI 2.0 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"
)

Staging issuer identifiers. Production integrations pass their own issuer via the product options' Issuer field.

View Source
const DefaultAuthContextType = "APP_AUTHENTICATION_DEFAULT"

DefaultAuthContextType is the standard Singpass/Corppass Login authentication_context_type — the flow a plain login authenticates for.

View Source
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

View Source
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 NewAgreerDecrypter

func NewAgreerDecrypter(agreer keys.ECDHAgreer) (keys.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) (keys.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 NewKeyManager

func NewKeyManager(sig crypto.Signer, sigKID string) (keys.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 ephemeral EC key generated here at startup. A DPoP key is sender-constraining and per-instance; it is never pre-registered, so it deliberately does not survive a restart.

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 NewMemorySessionStore

func NewMemorySessionStore(maxPending int) storage.SessionStore

NewMemorySessionStore returns an in-memory storage.SessionStore for FAPIgo's in-flight authorization state (state, nonce, PKCE verifier) 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 client.AssuranceProduction.

func OfflineClientJWKS

func OfflineClientJWKS(ctx context.Context, sigKey *ecdsa.PrivateKey, sigKID string, encKey *ecdsa.PrivateKey, 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.

FAPIgo's keys.PublicJWKS assembles the set straight from key material and the declared algorithms — no client engine, issuer or endpoints required — so the caller carries no JWK marshaling of its own. 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).

func RecommendedLimits

func RecommendedLimits(httpTimeout time.Duration) client.Limits

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 stays well under 10m but Corppass Myinfo Business issues a longer-lived id_token, so allow up to an hour.
  • 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.

Types

type ActingParty

type ActingParty struct {
	Subject     string // act.sub — the acting person's identifier
	SubjectType string // act.sub_type — typically "user"
}

ActingParty is the "act" (actor) claim: for Corppass Myinfo Business, the person who authenticated on behalf of the entity, distinct from the entity Subject.

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

func New(ctx context.Context, opts Options, deps Dependencies) (*Client, error)

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.

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, enables the Corppass /userinfo sub == client_id tolerance, and defaults to the Corppass issuer.

func (*Client) BeginLogin

func (c *Client) BeginLogin(ctx context.Context) (redirectURL string, state string, err error)

BeginLogin starts an authorization attempt: it runs the pushed authorization request and returns the URL to redirect the browser to, plus the opaque state handle (usable as a defense-in-depth cookie binding the request to its eventual callback).

func (*Client) Complete

func (c *Client) Complete(ctx context.Context, rawQuery string) (*Identity, error)

Complete validates the authorization callback (identified by its raw query string), exchanges the code for tokens, and returns the authenticated identity. A user-declined or server-denied login is reported as a *DeniedError.

func (*Client) PublicJWKS

func (c *Client) PublicJWKS(ctx context.Context) ([]byte, error)

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 DeniedError

type DeniedError struct {
	Code        string
	Description string
}

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 keys.KeyManager

	// Decryption recovers the content-encryption key of the encrypted id_token
	// (and /userinfo response). Required. See NewECDHDecrypter.
	Decryption keys.Decrypter

	// 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 storage.SessionStore

	// Assurance gates how strict FAPIgo's dependency validation is. Zero means
	// client.AssuranceDevelopment (permits the in-memory Sessions default).
	// client.AssuranceProduction requires a Sessions store declaring
	// storage.StoreAssurance (Durable + AtomicConsume), so the in-memory
	// default is refused.
	Assurance client.AssuranceLevel

	// HTTPClient is the base client for discovery, JWKS, PAR, token and
	// /userinfo calls. Nil builds &http.Client{Timeout: HTTPTimeout}.
	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 client.Clock

	// Random is the randomness source for state/nonce/PKCE. Nil means
	// crypto/rand.Reader.
	Random io.Reader

	// Limits bounds token lifetimes and JOSE/HTTP sizes. Nil means
	// RecommendedLimits(HTTPTimeout).
	Limits *client.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 *client.Algorithms

	// 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 — 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: client.AssuranceProduction.

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.

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

func (id *Identity) AssuranceContext() string

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

func (id *Identity) AssuranceLevel() string

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

func (id *Identity) Audience() []string

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

func (id *Identity) AuthMethods() []string

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

func (id *Identity) Issuer() string

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() map[string]any

SubjectAttributes returns the "sub_attributes" descriptor claims about the subject — for Corppass Myinfo Business the entity's name / registration number / type / status etc. Its members are flow-specific, so it is returned as the raw object for the caller to read; nil when the claim is absent.

func (*Identity) SubjectType

func (id *Identity) SubjectType() string

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 LoginOptions

type LoginOptions struct {
	Name        string   // app slug for logging/Identity; defaults to "login"
	Issuer      string   // FAPI issuer; defaults to StagingSingpassIssuer
	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.
	AuthContextType 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 + DPoP signer
	SigningKID    string            // kid of the registered signing key
	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 keys.ECDHAgreer
}

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"
	Issuer      string   // FAPI issuer; defaults to StagingCorppassIssuer
	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 + DPoP signer
	SigningKID    string            // kid of the registered signing key
	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 keys.ECDHAgreer
}

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 and the /userinfo sub == client_id tolerance enabled.

type MyinfoOptions

type MyinfoOptions struct {
	Name        string   // app slug for logging/Identity; defaults to "myinfo"
	Issuer      string   // FAPI issuer; defaults to StagingSingpassIssuer
	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

	SigningKey    crypto.Signer     // ES256 / P-256 client-assertion + DPoP signer
	SigningKID    string            // kid of the registered signing key
	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 keys.ECDHAgreer
}

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)
	AcrValues       string   // optional requested level of assurance; "" to omit
	FetchUserInfo   bool     // call the FAPI /userinfo endpoint after token exchange (Myinfo)
	// TolerateUserInfoSubjectClientID accepts a /userinfo "sub" equal to the
	// client_id (as well as the id_token sub) — a Corppass Myinfo Business
	// deviation from OIDC Core §5.3.2. Set only for the Myinfo Business client.
	TolerateUserInfoSubjectClientID bool
}

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.

Directories

Path Synopsis
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 envelope-aware data model for a Myinfo / Myinfo Business /userinfo response: Response groups the data blocks, Data and Field read each item's Myinfo envelope (value or code+desc, source, classification, lastupdated, unavailable), and Authorisation flattens Corppass auth_info.
Package myinfo is the envelope-aware data model for a Myinfo / Myinfo Business /userinfo response: Response groups the data blocks, Data and Field read each item's Myinfo envelope (value or code+desc, source, classification, lastupdated, unavailable), and Authorisation flattens Corppass auth_info.
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.

Jump to

Keyboard shortcuts

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