Skip to content

About

Unofficial community Go library for Singpass Login, Myinfo and Myinfo Business (FAPI 2.0), plus a Docker mock Singpass/Corppass server for testing apps in any language

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

129 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

singpass-client-go

Go Reference CI Release License: MIT 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

About

Unofficial community Go library for Singpass Login, Myinfo and Myinfo Business (FAPI 2.0), plus a Docker mock Singpass/Corppass server for testing apps in any language

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages