Your values Fill in your own to see them in every example
On the app’s page in the hub, under Credentials.
That doesn’t look right yet. For example: q3Jx8vN2mZ0bT5sLwK7yHc4dR1fGaE9uPiO6nVjXkMs
Scheme and host, no path: where your app runs.
That doesn’t look right yet. For example: https://app.example.com
Saved in this browser only, for every page of these docs. Nothing you type leaves this page.
Signing in is a standard OpenID Connect sign-in against your organization’s issuer. Nothing here is specific to Trustaige except what the ID token carries and the wording of refusals.
Sign in
Use any OpenID Connect library, with the authorization code flow and PKCE.
Point it at your issuer (TRUSTAIGE_ISSUER); discovery gives it every endpoint
and your organization’s own signing keys (ES256). Request the openid scope.
provider, _ := oidc.NewProvider(ctx, os.Getenv("TRUSTAIGE_ISSUER"))
config := oauth2.Config{
ClientID: os.Getenv("TRUSTAIGE_CLIENT_ID"),
ClientSecret: os.Getenv("TRUSTAIGE_CLIENT_SECRET"),
RedirectURL: "<your-app-url>/auth/callback",
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID},
}
verifier := provider.Verifier(&oidc.Config{ClientID: config.ClientID})
The callback
The callback is where most integrations go wrong, so here is the whole of it.
Keep the state, nonce and PKCE verifier in a short-lived cookie when the
sign-in starts. On the way back, check each one, exchange the code, verify the
token, and look the customer up by sub.
func (a *Auth) Callback(w http.ResponseWriter, r *http.Request) {
if e := r.URL.Query().Get("error"); e != "" {
// error_description is attacker-controlled: choose your own message.
reason := "cancelled"
if e == "access_denied" {
d := r.URL.Query().Get("error_description")
switch {
case strings.Contains(d, "don't have an account"):
reason = "no_account"
case strings.Contains(d, "cancelled the sign-in"), strings.Contains(d, "denied the authorization request"):
reason = "cancelled"
default:
reason = "inactive"
}
}
a.askForPasskeyNextTime(w) // prompt=login on the next attempt
http.Redirect(w, r, "/signin?error="+reason, http.StatusFound)
return
}
txn, err := a.readAndClearTxnCookie(w, r) // state, nonce, PKCE verifier
if err != nil || r.URL.Query().Get("state") != txn.State {
a.fail(w, r, "mismatch")
return
}
token, err := a.oauth2.Exchange(r.Context(), r.URL.Query().Get("code"),
oauth2.VerifierOption(txn.Verifier))
if err != nil {
a.fail(w, r, "failed")
return
}
raw, _ := token.Extra("id_token").(string)
idToken, err := a.verifier.Verify(r.Context(), raw) // signature, iss, aud, exp
if err != nil || idToken.Nonce != txn.Nonce {
a.fail(w, r, "mismatch")
return
}
customer, err := a.store.CustomerBySubject(r.Context(), idToken.Subject)
if err != nil {
a.fail(w, r, "no_account")
return
}
if customer.EmailVerifiedAt == nil {
a.store.MarkEmailVerified(r.Context(), customer.ID) // first sign-in from your link
}
a.startSession(w, customer.ID, raw) // keep the ID token for sign-out
a.clearAskForPasskey(w)
http.Redirect(w, r, txn.ReturnTo, http.StatusFound)
}
What the ID token carries
| Claim | Meaning |
|---|---|
sub | This person for your organization: the subject you were given. Look your customer up by it. |
credential.type | The credential that admitted them, such as Customer. If they hold more than one your app admits, Trustaige asks which, and this is the one they chose. |
credential.claims | The values you chose at offer, such as { "tier": "standard" }. |
credential.document_number | This particular credential. It changes when the credential renews: never key an account on it. |
credential.issuer | The organization that issued it. Yours, unless a collaborator shared it with you. |
credential.expires_at | When it ends, in seconds since the epoch. Absent if it doesn’t end. |
There’s no email or name in the token: you already hold them, and Trustaige doesn’t. There’s no password, so there’s nothing to reset. A person who loses their device recovers their own identity with Trustaige; you’re not involved. Paste a token into the token decoder to see it.
provider, _ := oidc.NewProvider(ctx, os.Getenv("TRUSTAIGE_ISSUER"))
config := oauth2.Config{
ClientID: os.Getenv("TRUSTAIGE_CLIENT_ID"),
ClientSecret: os.Getenv("TRUSTAIGE_CLIENT_SECRET"),
RedirectURL: "<your-app-url>/auth/callback",
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID},
}
verifier := provider.Verifier(&oidc.Config{ClientID: config.ClientID})func (a *Auth) Callback(w http.ResponseWriter, r *http.Request) {
if e := r.URL.Query().Get("error"); e != "" {
// error_description is attacker-controlled: choose your own message.
reason := "cancelled"
if e == "access_denied" {
d := r.URL.Query().Get("error_description")
switch {
case strings.Contains(d, "don't have an account"):
reason = "no_account"
case strings.Contains(d, "cancelled the sign-in"), strings.Contains(d, "denied the authorization request"):
reason = "cancelled"
default:
reason = "inactive"
}
}
a.askForPasskeyNextTime(w) // prompt=login on the next attempt
http.Redirect(w, r, "/signin?error="+reason, http.StatusFound)
return
}
txn, err := a.readAndClearTxnCookie(w, r) // state, nonce, PKCE verifier
if err != nil || r.URL.Query().Get("state") != txn.State {
a.fail(w, r, "mismatch")
return
}
token, err := a.oauth2.Exchange(r.Context(), r.URL.Query().Get("code"),
oauth2.VerifierOption(txn.Verifier))
if err != nil {
a.fail(w, r, "failed")
return
}
raw, _ := token.Extra("id_token").(string)
idToken, err := a.verifier.Verify(r.Context(), raw) // signature, iss, aud, exp
if err != nil || idToken.Nonce != txn.Nonce {
a.fail(w, r, "mismatch")
return
}
customer, err := a.store.CustomerBySubject(r.Context(), idToken.Subject)
if err != nil {
a.fail(w, r, "no_account")
return
}
if customer.EmailVerifiedAt == nil {
a.store.MarkEmailVerified(r.Context(), customer.ID) // first sign-in from your link
}
a.startSession(w, customer.ID, raw) // keep the ID token for sign-out
a.clearAskForPasskey(w)
http.Redirect(w, r, txn.ReturnTo, http.StatusFound)
}Sign out
End your own session, then send the browser to the end-session endpoint from discovery, with the ID token as a hint and your registered post-logout URI.
GET https://id.trustaige.com/oauth2/logout
?client_id=<your-client-id>
&id_token_hint=<the ID token>
&post_logout_redirect_uri=<your-app-url>/
GET https://id.trustaige.com/oauth2/logout
?client_id=<your-client-id>
&id_token_hint=<the ID token>
&post_logout_redirect_uri=<your-app-url>/When sign-in is refused
A refused sign-in comes back to your callback as a standard OAuth error, with
no code and no token. The error_description arrives in your own URL, where
anyone can put text: use it to choose between your own messages, and never show
it.
error | Means | Tell the person |
|---|---|---|
access_denied, “don’t have an account” | The passkey belongs to someone with no account with you | There’s no account for this passkey. Create one, or use a different passkey. |
access_denied, “no longer active” | Their credential is suspended, revoked or expired | Their account isn’t active, and how to reach you |
access_denied, “don’t hold a credential this application accepts” | None of their credentials is one you accept, or a collaborator stopped sharing it | They can’t sign in here with what they hold |
access_denied, “denied the authorization request” | They chose not to share a collaborator’s credential with you | Nothing to show; they can try again |
access_denied, “cancelled the sign-in” | They went back from the Trustaige sign-in card to you | Nothing to show; offer sign-in and sign-up as usual |
| any other error | They cancelled, or something failed | Sign-in didn’t complete. Try again. |
After any refusal, ask for the passkey on the next attempt (prompt=login), so
the person can choose a different passkey instead of being refused again by the
same Trustaige session.
Next: after sign-up.