Skip to content
Trustaige Docs

Sign customers in and out

Sign customers in with any OpenID Connect library, read the ID token, sign them out properly, and handle every way a sign-in can be refused.

View as Markdown

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

ClaimMeaning
subThis person for your organization: the subject you were given. Look your customer up by it.
credential.typeThe 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.claimsThe values you chose at offer, such as { "tier": "standard" }.
credential.document_numberThis particular credential. It changes when the credential renews: never key an account on it.
credential.issuerThe organization that issued it. Yours, unless a collaborator shared it with you.
credential.expires_atWhen 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.

errorMeansTell the person
access_denied, “don’t have an account”The passkey belongs to someone with no account with youThere’s no account for this passkey. Create one, or use a different passkey.
access_denied, “no longer active”Their credential is suspended, revoked or expiredTheir 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 itThey 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 youNothing to show; they can try again
access_denied, “cancelled the sign-in”They went back from the Trustaige sign-in card to youNothing to show; offer sign-in and sign-up as usual
any other errorThey cancelled, or something failedSign-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.