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

singpass

package module
v0.16.1 Latest Latest
Warning

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

Go to latest
Published: Oct 15, 2026 License: MIT Imports: 28 Imported by: 0

README

singpass-client-go

Go Reference CI Release Quality Gate Coverage Security Rating Maintainability Rating

A Go library for integrating Singpass Login, Myinfo and Myinfo Business (Corppass) over the FAPI 2.0 Security Profile — PAR, DPoP, private_key_jwt, PKCE, encrypted id_tokens and /userinfo — without rediscovering the Singpass/Corppass-specific details. The protocol is driven by FAPIgo; this library supplies the Singpass pieces.

Unofficial. A community project, not affiliated with or endorsed by GovTech, Singpass or Corppass.

  • One constructor per product — NewLogin, NewMyinfo, NewMyinfoBusiness — with working staging defaults.
  • Typed Myinfo data — PersonProfile() and EntityProfile() name the common items, with dates, numbers, addresses and phone numbers parsed; provenance kept on every item; Corppass's double-encoded blocks and auth_info handled for you.
  • HSM/KMS-ready — the signing key is any crypto.Signer; decryption can use an external ECDH agreer, so private keys needn't enter the process.
  • Optional net/http helper — login, callback, logout and JWKS routes; you render the pages.
  • Test without onboarding — singpasstest runs a fake Singpass/Corppass server in-process for integration tests, the singpass-fake-server Docker image runs it standalone for apps in any language, and the demo runs against it with one environment variable.
  • Production pieces included — Environment: singpass.Production, durable session stores (sqlstore), key rotation without downtime, a published-JWKS check, retries for temporary Singpass errors, browser security headers (web.SecureHeaders) and a key generator (singpass-keygen).

Try it now, no Singpass account or onboarding needed:

git clone https://github.com/osanderson/singpass-client-go
cd singpass-client-go/examples/demo
DEMO_MOCK=1 go run ./cmd/server   # open http://localhost:8088
Testing in any language

Not using Go? The fake Singpass and Corppass servers are a Docker image, for local development and CI with apps in any language:

docker run --rm -p 5156:5156 -p 5157:5157 ghcr.io/osanderson/singpass-fake-server

Singpass is then at http://localhost:5156/fapi and Corppass at http://localhost:5157, each with discovery, PAR, DPoP, private_key_jwt, encrypted id_tokens and /userinfo as the real FAPI 2.0 endpoints do them, built-in test clients and users, and a dashboard at /_fake/. See Testing your integration and docs/docker.md for Docker Compose, GitHub Actions and HTTPS.

Install

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

Requires Go 1.26.9+ (FAPIgo's minimum, which fixes GO-2026-6617). The package name is singpass.

Quick start

A complete Singpass Login app — also in examples/minimal:

sigKey, _ := keyfile.LoadECPrivateKey("keys/sig.pem") // keys you registered with Singpass
encKey, _ := keyfile.LoadECPrivateKey("keys/enc.pem")

client, err := singpass.NewLogin(ctx, singpass.LoginOptions{
    ClientID:      "your-client-id",
    RedirectURI:   "https://app.example.com/login/callback",
    Scopes:        []string{"openid", "name"},
    SigningKey:    sigKey,
    SigningKID:    "login-sig-1",
    EncryptionKey: encKey,
    EncryptionKID: "login-enc-1",
}, singpass.Dependencies{}) // zero value = staging defaults
if err != nil {
    log.Fatal(err)
}
jwks, _ := client.PublicJWKS(ctx)

h := web.New(web.Config{
    Apps:            []*web.App{{Name: "login", Title: "Singpass", Auth: client, JWKS: jwks}},
    SessionIdentity: web.MinimalIdentity, // keep personal data out of the login session
    LoginRateLimit:  &web.LoginRateLimit{}, // 10 logins at once per IP, then 10 a minute
    // Behind HTTPS, also set Cookies: web.DefaultCookieConfig(true) (Secure, __Host- names).
})
mux := h.Mux() // /login/login, /login/callback, /login/logout, /login/jwks.json
mux.Handle("/", web.NoStore(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    if id, ok := h.CurrentIdentity(r); ok { // never cache pages showing the identity
        fmt.Fprintf(w, "Signed in as %s", id.Subject)
        return
    }
    fmt.Fprint(w, `<a href="/login/login">Log in with Singpass</a>`)
})))
log.Fatal(http.ListenAndServe(":8080", web.SecureHeaders(mux))) // CSP, anti-framing, HSTS, …

Not using the helper? client.BeginLogin(ctx) returns the redirect URL and the login's state — keep it in a cookie — and client.Complete(ctx, r.URL.RawQuery, state) validates the callback against that browser's state and returns the *singpass.Identity — see the Client.Complete example. A cancelled or denied login comes back as a *singpass.DeniedError, and a stale one (expired, reloaded or replayed callback) matches errors.Is(err, singpass.ErrLoginExpired) — show a "please try again" page for it rather than an error. singpass.ErrorCode(err) gives the error code Singpass sent, and singpass.IsTemporary(err) says whether trying again may help (Dependencies.BeginLoginRetries retries BeginLogin for you).

For Singpass Login, LoginOptions.AuthContextMessage — or client.BeginLoginWith(ctx, singpass.LoginContext{Message: …}) for one login — tells the user what they're authenticating for, and AppClaimedHTTPS / AppLaunchURL cover logins started from a mobile app.

New to Singpass? The onboarding guide walks through the developer portal and maps each setting to these options.

Need keys to register? The singpass-keygen command creates both keys (or reuses existing ones — it never overwrites) and prints the public JWKS:

go install github.com/osanderson/singpass-client-go/cmd/singpass-keygen@latest
singpass-keygen -dir keys/login -sig-kid login-sig-1 -enc-kid login-enc-1 > login.jwks.json

To publish several keys, as in a key rotation, list each one with -sig PATH=KID / -enc PATH=KID instead of -dir. In code, keyfile.LoadOrGenerate and singpass.OfflineClientJWKS do the same (example).

Once your JWKS is published at the URL you registered, check it serves these keys. A JWKS URL with stale or other keys otherwise shows up at login only as invalid_client:

singpass-keygen -dir keys/login -sig-kid login-sig-1 -enc-kid login-enc-1 -check https://app.example.com/login/jwks.json

From a running app, client.CheckPublishedJWKS(ctx, url) does the same, e.g. as a readiness check. The constructors also check your options up front: a missing openid scope, a malformed, non-https or IP-address redirect URI and an empty key ID fail at startup, with an error naming the option, rather than at Singpass.

Test accounts

Singpass and Corppass publish staging test personas with Myinfo data: Myinfo test personas and Myinfo Business test personas. Log in with the persona's UINFIN and the password given on those pages. The Myinfo personas also sign in to staging Singpass Login apps, but Singpass doesn't support that use. Persona data changes without notice, so don't hard-code it. No staging client yet? Try the demo with DEMO_MOCK=1, or run your own app against singpass-fake-server.

Myinfo person data

NewMyinfo and NewMyinfoBusiness take options shaped like NewLogin's. Myinfo scopes are per data item; myinfo.Scopes expands items to the scopes Singpass's data catalogue lists for them (vehicles alone is 37): Scopes: myinfo.Scopes("openid", myinfo.ItemName, myinfo.ItemVehicles). For Myinfo Business, use the block-prefixed items: myinfo.Scopes("openid", myinfo.EntityBasicProfile, myinfo.EntityAppointments, myinfo.UserName). Complete then also calls /userinfo and fills Identity.Myinfo. The typed profiles name the common items, so there are no keys to look up:

p := id.Myinfo.PersonProfile()
p.Name.String()                          // "TAN XIAO HUI"
p.Nationality.Code()                     // "SG"  (.String() gives "SINGAPORE CITIZEN")
dob, ok := p.DOB.Date()                  // time.Time
p.RegAdd.Lines()                         // ["102 BEDOK NORTH AVENUE 4", "#09-128", "SINGAPORE 460102"]
p.MobileNo.E164()                        // "+6597399245"
p.CPFBalances.OA.Float()                 // 1581.48, true
p.NOABasic.Amount.Float()                // latest assessable income; .NOA for the breakdown
p.Vehicles[0].COEExpiryDate.Date()       // also HDBOwnership, DrivingLicence, NOAHistory, CPFContributions, …
p.PartialUINFIN.String()                 // "****381D": masked NRIC, for display without the full number
p.Email.Available()                      // false when Myinfo has no value
p.Name.SourceCode()                      // myinfo.SourceGovernmentVerified

e := id.Myinfo.EntityProfile()           // Myinfo Business
e.Name.String(); e.RegistrationNumber.String(); e.UENStatus.Code(); e.Address.String()
e.Appointments; e.Shareholders           // person or entity party, flattened
e.Financials[0].Company.Revenue.Float()  // also Capitals, Licences, Grants, History, …
id.Myinfo.CorppassProfile().Email        // the acting user's Corppass account
id.Myinfo.Auth.Authorisations()          // Corppass auth_info, flattened

Every item is a myinfo.Field, keeping its value, code, provenance and classification. For items the profiles don't model, read the blocks by key:

id.Myinfo.Person.Object("drivinglicence") // .Entity, .Corppass, .Auth, .TPAuth for Business
id.Myinfo.Person.List("vehicles")        // repeated records
id.Myinfo.Person.Leaves()                // every item with its key path, e.g. for a table or a DB row
p.RegAdd.Data.EffectiveSource()          // provenance, incl. source declared on the container
myinfo.Label("hdbownership")             // "HDB ownership" — display labels for keys
id.Myinfo.Raw()                          // the full decoded response

Absent blocks and fields return zero values rather than panicking. The id_token claims have typed accessors too: id.Issuer(), id.AuthMethods(), id.AssuranceLevel(), id.SubjectType(), and id.SubjectAttributes() with named fields (IdentityNumber, AccountType, Name, … for a person; EntityName, EntityRegNumber, … for a Corppass entity). For Corppass, id.ActingParty() is the person acting for the entity, with the same typed Attributes.

Packages

Import Purpose
singpass-client-go Client, the product constructors, Identity, DeniedError
…/myinfo Myinfo data model; standard library only
…/web Optional net/http handlers and login sessions
…/keyfile Generate and load EC P-256 PEM keys
…/singpasstest Fake Singpass / Corppass server for tests and demos
…/sqlstore Durable session stores on Postgres, MySQL or SQLite (database/sql, no dependencies)
…/cmd/singpass-keygen Command: create a client's keys and print the JWKS to register
…/cmd/singpass-fake-server Command: run the fake Singpass and Corppass servers standalone, for apps in any language

Testing your integration

singpasstest runs a fake Singpass or Corppass authorization server in-process, built on FAPIgo's real server engine: PAR, DPoP, private_key_jwt, PKCE, encrypted id_tokens and signed, encrypted /userinfo, plus the Singpass/Corppass quirks. Register your client with it, point the client at srv.Issuer() with Dependencies{AllowLoopbackHTTP: true}, and drive a full login in a test. Its built-in test users carry Myinfo data across the typed datasets (CPF, income tax, HDB, vehicles, driving licence, children, a foreigner's pass), and its sign-in page — or singpasstest.UserPersona / EntityPersona in code — logs in as any NRIC, FIN or UEN you choose. See the example.

To use the fake servers from an app in any language, or by hand in a browser, run them standalone:

docker run --rm -p 5156:5156 -p 5157:5157 ghcr.io/osanderson/singpass-fake-server
# or, with Go:
go run github.com/osanderson/singpass-client-go/cmd/singpass-fake-server@latest

The image runs Singpass at http://localhost:5156/fapi and Corppass at http://localhost:5157, each with a dashboard at /_fake/ showing its clients, test users and recent requests. docs/docker.md covers configuring it, GitHub Actions, Docker Compose, and HTTPS with a test CA for apps that reach it by a service name.

With no configuration it registers built-in test clients — login-test, myinfo-test and myinfo-business-test, allowed every scope and any http://localhost redirect URI — whose keys it serves at /_fake/test-client/jwks.json. Those keys are published, so they're for this fake only. To use your app's own keys, register it with -client-id, -client-product, -client-redirect-uris, -client-scopes and -client-jwks-url, or register several clients from a JSON file with -config. Every flag can also be set from an environment variable (-singpass-url is FAKE_SINGPASS_URL). See the command's documentation.

Choosing the user in a headless test. Send MockPass's X-Custom-* headers on the authorization request (the GET of the URL BeginLogin returns) and the login is approved straight away as that user, even with the sign-in page on:

Header Value
X-Custom-NRIC the NRIC or FIN: a test user's, or any other for a new user
X-Custom-UEN Corppass only, and required there: the entity
X-Custom-UUID optional: replaces the user's Singpass UUID (sub, or act.sub on Corppass)
X-Custom-Name optional: a new user's name
X-Custom-Error access_denied: the user cancels

Each login chooses its own user, so parallel tests don't interfere. In Go, srv.AuthorizeAs(ctx, url, singpasstest.LoginAs{NRIC: "S8012345F"}) does the same.

Your own test users. Add them from a JSON file — to the standalone server with -personas users.json, to the demo's mock mode with DEMO_MOCK_PERSONAS=users.json, or to a test with singpasstest.LoadPersonas. Each user is an NRIC (or, for Corppass, a UEN and the acting person's NRIC) with optional /userinfo data in Myinfo's shape, so a real staging response — like the demo's "Raw /userinfo response" — can be pasted in as-is:

[{"nric": "S1234567D", "userinfo": {"person_info": {"name": {"value": "TAN AH KOW"}}}}]

Going to production

The defaults target staging. For production, set Environment: singpass.Production in the product options: it selects the production issuer and production assurance, under which the in-memory session store is refused, so supply a durable Dependencies.Sessions — sqlstore provides one (and the web helper's login sessions) on Postgres, MySQL or SQLite — declare durable keys with Dependencies.KeyCustody: singpass.KeyCustody{Durable: true}, and give every instance the same DPoPKey, since Singpass binds each login to the DPoP key it started with. Work through the go-live checklist, which also covers rotating keys without downtime.

Documentation

Stability

The project is pre-1.0 and follows semantic versioning:

  • Patch releases (0.9.x) never break the API.
  • Minor releases (0.x.0) may. Each breaking change is marked in the CHANGELOG and explained, with before/after code, in UPGRADING.md.
  • 1.0 will follow once the API has held through real integrations of all three products and a release cycle without breaking changes; from then on, breaking changes wait for a major version.

Much of the churn so far has come from tracking FAPIgo, which is also pre-1.0; this library absorbs its changes where it can, so most FAPIgo upgrades need nothing from you.

Contributing and security

Contributions are welcome — see CONTRIBUTING.md. Please report vulnerabilities privately as described in SECURITY.md.

License

MIT

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

Examples

Constants

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

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

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 CheckPublishedJWKS added in v0.8.0

func CheckPublishedJWKS(ctx context.Context, hc *http.Client, jwksURL string, want []byte) error

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

func ErrorCode(err error) string

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

func IsTemporary(err error) bool

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

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.

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
}

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

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

func (c *Client) CheckPublishedJWKS(ctx context.Context, jwksURL string) error

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

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

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)
	})
}

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 Clock added in v0.2.0

type Clock = client.Clock

Clock supplies the current time: Dependencies.Clock.

type Decrypter added in v0.2.0

type Decrypter = keys.Decrypter

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

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

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() 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

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

type Limits = client.Limits

Limits bounds token lifetimes, sizes and timeouts: Dependencies.Limits. See RecommendedLimits.

func RecommendedLimits

func RecommendedLimits(httpTimeout time.Duration) 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 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

type LoginContext struct {
	Type    string
	Message string
}

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

type PublishedKey struct {
	Key *ecdsa.PublicKey // P-256
	KID string
}

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.

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.

Jump to

Keyboard shortcuts

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