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

singpass

package module
v0.5.1 Latest Latest
Warning

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

Go to latest
Published: Sep 26, 2026 License: MIT Imports: 24 Imported by: 0

README

singpass-client-go

Go Reference CI Release

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.
  • Myinfo data without map-casting — envelope-aware accessors for values, codes, provenance and nested records; 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, and the demo runs against it with one environment variable.

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

Install

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

Requires Go 1.26.6+ (FAPIgo's minimum). 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}},
})
mux := h.Mux() // /login/login, /login/callback, /login/logout, /login/jwks.json
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    if id, ok := h.CurrentIdentity(r); ok {
        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", mux))

Not using the helper? client.BeginLogin(ctx) returns the redirect URL and a state handle, and client.Complete(ctx, r.URL.RawQuery) validates the callback 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.

Need keys to register? keyfile.GenerateECKey and singpass.OfflineClientJWKS produce them and the public JWKS (example), or run the demo's go run ./cmd/keygen.

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.

Myinfo person data

NewMyinfo and NewMyinfoBusiness take the same options; Complete then also calls /userinfo and fills Identity.Myinfo:

p := id.Myinfo.Person                    // .Entity, .Corppass, .Auth, .TPAuth for Business
p.Field("name").String()                 // "TAN XIAO HUI"
p.Field("nationality").Code()            // "SG"  (.String() gives "SINGAPORE CITIZEN")
p.Field("email").Available()             // false when Myinfo has no value
p.Field("name").SourceCode()             // myinfo.SourceGovernmentVerified
p.Object("regadd").Field("postal").String()
p.List("vehicles")                       // repeated records
id.Myinfo.Auth.Authorisations()          // Corppass auth_info, flattened
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(), id.ActingParty(), id.SubjectAttributes().

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)

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 — see the example.

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. Work through the go-live checklist.

Documentation

Stability

The project is pre-1.0: minor versions may change the API, and every change is listed in the CHANGELOG. Releases follow semantic versioning.

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.

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

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

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
	// account_type, identity_number, identity_coi and name. Nil when absent.
	Attributes map[string]any
}

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

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) {
		c, err := r.Cookie("sp_state")
		if err != nil || c.Value != r.URL.Query().Get("state") {
			http.Error(w, "login expired, please try again", http.StatusBadRequest)
			return
		}
		id, err := client.Complete(r.Context(), r.URL.RawQuery)
		var denied *singpass.DeniedError
		switch {
		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.

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

	// 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 &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 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 *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, 127.0.0.0/8, ::1) — for a local fake authorization
	// server such as singpasstest's. Development only: New refuses it together
	// with AssuranceProduction.
	AllowLoopbackHTTP bool

	// 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: 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",
		},
		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.IDTokenExpiry.Sub(id.IDTokenIssuedAt))
}
Output:
https://stg-id.singpass.gov.sg/fapi
[your-client-id]
[pwd otp-sms]
LOA 2
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() map[string]any

SubjectAttributes returns the "sub_attributes" descriptor claims about the subject. For a Singpass person they are released per scope — user.identity gives account_type, identity_number and identity_coi; name, email and mobileno give the same-named item. For a Corppass entity they are the entity's type, registration number, country, name and UEN status. 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 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 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.

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

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.
	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 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"
	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 + 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 ECDHAgreer

	// TolerateUserInfoSubjectClientID also accepts a /userinfo "sub" equal to
	// the client_id. Corppass used to send that instead of the id_token's sub
	// (contrary to OIDC Core §5.3.2); it now sends the correct sub — confirmed
	// on staging — so this is off by default. Set it only if a Corppass
	// environment still fails with a /userinfo subject mismatch.
	TolerateUserInfoSubjectClientID bool
}

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

	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 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 deviation from OIDC Core
	// §5.3.2 that Corppass Myinfo Business used to have and has since fixed.
	// Off by default; see MyinfoBusinessOptions.TolerateUserInfoSubjectClientID.
	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.

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

Directories

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