-
-
Notifications
You must be signed in to change notification settings - Fork 204
Description
RFC: Passkeys / WebAuthn Support
Phase: 5 — Advanced Security & Enterprise
Priority: P2 — Medium
Estimated Effort: Medium
Problem Statement
Authorizer has no passwordless authentication via passkeys (FIDO2/WebAuthn). Keycloak 26.4 and Clerk both support passkeys. The industry is moving toward passwordless — Apple, Google, and Microsoft all promote passkeys as the primary authentication method. Passkeys are phishing-resistant, require no passwords to remember, and provide a superior user experience.
Current Architecture Context
- MFA/TOTP exists via
internal/authenticators/(Google Authenticator) - Authenticator schema:
ID,UserID,Method,Secret,RecoveryCodes,VerifiedAt - Login flow in
internal/graphql/login.goreturnsAuthResponsewith optional MFA challenge - No WebAuthn library in
go.mod web/app/(React) handles login UI
Proposed Solution
1. WebAuthn Library
Library: go-webauthn/webauthn — the most maintained Go WebAuthn library, supports FIDO2, passkeys, and all attestation formats.
2. WebAuthn Credential Schema
New schema: internal/storage/schemas/webauthn_credential.go
type WebAuthnCredential struct {
ID string `json:"id" gorm:"primaryKey;type:char(36)"`
UserID string `json:"user_id" gorm:"type:char(36);index:idx_webauthn_user"`
CredentialID string `json:"credential_id" gorm:"type:text;uniqueIndex"` // base64url-encoded
PublicKey string `json:"public_key" gorm:"type:text"` // CBOR-encoded public key
AttestationType string `json:"attestation_type" gorm:"type:varchar(50)"` // none, packed, tpm, etc.
Transport string `json:"transport" gorm:"type:varchar(256)"` // JSON array: ["internal", "usb", "ble", "nfc"]
SignCount int64 `json:"sign_count" gorm:"default:0"` // monotonic counter for cloning detection
AAGUID string `json:"aaguid" gorm:"type:varchar(36)"` // authenticator attestation GUID
Name string `json:"name" gorm:"type:varchar(256)"` // user-provided label: "MacBook Pro Touch ID"
LastUsedAt int64 `json:"last_used_at"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"`
}3. Registration Flow
Step 1: Begin registration — server generates challenge
New REST endpoint: POST /webauthn/register/begin
func (h *httpProvider) WebAuthnRegisterBeginHandler() gin.HandlerFunc {
return func(c *gin.Context) {
// Requires authenticated session
user := getAuthenticatedUser(c)
// Check credential limit
existingCreds, _ := store.ListWebAuthnCredentialsByUserID(ctx, user.ID)
if len(existingCreds) >= maxPasskeysPerUser {
return error("max_passkeys_reached")
}
// Create WebAuthn user adapter
webauthnUser := &WebAuthnUser{
ID: user.ID,
Name: user.Email,
DisplayName: user.GivenName + " " + user.FamilyName,
Credentials: convertToWebAuthnCredentials(existingCreds),
}
// Generate registration options
options, sessionData, err := webauthn.BeginRegistration(webauthnUser,
webauthn.WithExcludeCredentials(webauthnUser.CredentialDescriptors()),
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
webauthn.WithUserVerification(protocol.VerificationPreferred),
)
// Store session data in memory store (TTL: 5 minutes)
memoryStore.SetWebAuthnSession(user.ID, "register", sessionData, 5*time.Minute)
c.JSON(200, options)
}
}Step 2: Finish registration — client sends attestation
New REST endpoint: POST /webauthn/register/finish
func (h *httpProvider) WebAuthnRegisterFinishHandler() gin.HandlerFunc {
return func(c *gin.Context) {
user := getAuthenticatedUser(c)
// Retrieve session data
sessionData, _ := memoryStore.GetWebAuthnSession(user.ID, "register")
// Verify attestation
credential, err := webauthn.FinishRegistration(webauthnUser, *sessionData, c.Request)
// Store credential
store.AddWebAuthnCredential(ctx, &schemas.WebAuthnCredential{
UserID: user.ID,
CredentialID: base64.RawURLEncoding.EncodeToString(credential.ID),
PublicKey: base64.StdEncoding.EncodeToString(credential.PublicKey),
AttestationType: credential.AttestationType,
Transport: marshalTransports(credential.Transport),
AAGUID: credential.Authenticator.AAGUID.String(),
Name: c.PostForm("name"), // user-provided label
})
// Cleanup session
memoryStore.DeleteWebAuthnSession(user.ID, "register")
c.JSON(200, gin.H{"message": "passkey_registered"})
}
}4. Authentication Flow
Step 1: Begin login — POST /webauthn/login/begin
func (h *httpProvider) WebAuthnLoginBeginHandler() gin.HandlerFunc {
return func(c *gin.Context) {
email := c.PostForm("email") // optional — for non-discoverable credentials
var options *protocol.CredentialAssertion
var sessionData *webauthn.SessionData
if email != "" {
// User-identified flow — show only this user's credentials
user, _ := store.GetUserByEmail(ctx, email)
creds, _ := store.ListWebAuthnCredentialsByUserID(ctx, user.ID)
webauthnUser := &WebAuthnUser{ID: user.ID, Credentials: convertCreds(creds)}
options, sessionData, _ = webauthn.BeginLogin(webauthnUser)
} else {
// Discoverable credential flow (passkeys) — no email needed
options, sessionData, _ = webauthn.BeginDiscoverableLogin()
}
// Store session in memory store
sessionKey := email
if sessionKey == "" {
sessionKey = "discoverable"
}
memoryStore.SetWebAuthnSession(sessionKey, "login", sessionData, 5*time.Minute)
c.JSON(200, options)
}
}Step 2: Finish login — POST /webauthn/login/finish
func (h *httpProvider) WebAuthnLoginFinishHandler() gin.HandlerFunc {
return func(c *gin.Context) {
// For discoverable credentials, the credential response includes the user handle
// which maps to user.ID — no email needed
credential, err := webauthn.FinishDiscoverableLogin(
func(rawID, userHandle []byte) (webauthn.User, error) {
userID := string(userHandle)
user, _ := store.GetUserByID(ctx, userID)
creds, _ := store.ListWebAuthnCredentialsByUserID(ctx, userID)
return &WebAuthnUser{ID: user.ID, Credentials: convertCreds(creds)}, nil
},
*sessionData,
c.Request,
)
// Update sign count (clone detection)
store.UpdateWebAuthnCredentialSignCount(ctx, credentialID, credential.Authenticator.SignCount)
// Sign count validation: if new count <= stored count, possible cloned authenticator
if credential.Authenticator.SignCount <= storedSignCount && storedSignCount > 0 {
// Log security warning, optionally reject
auditProvider.Log(ctx, audit.AuditEvent{
Action: "user.webauthn_clone_detected",
// ...
})
}
// Create session and issue tokens (same as password login)
createSessionAndReturnTokens(c, user, "passkey")
}
}5. Passkey Modes
As primary auth (skip password):
- User registers passkey → can log in with only passkey, no password needed
SignupMethodsupdated to include "passkey"
As MFA second factor:
- After password verification, prompt for passkey instead of TOTP
- Stronger than TOTP (phishing-resistant)
Configuration: --passkey-mode=primary (primary = standalone login, mfa = second factor only, both = user choice)
6. Conditional UI (Autofill)
When WebAuthn conditional mediation is available, the browser can suggest passkeys in the login form's email field autofill dropdown:
// In web/app/ login component:
if (window.PublicKeyCredential?.isConditionalMediationAvailable) {
const available = await PublicKeyCredential.isConditionalMediationAvailable();
if (available) {
// Add autocomplete="webauthn" to email input
// Browser shows passkey suggestions in autofill dropdown
navigator.credentials.get({
publicKey: assertionOptions,
mediation: "conditional"
});
}
}7. Storage Interface Methods
AddWebAuthnCredential(ctx context.Context, cred *schemas.WebAuthnCredential) (*schemas.WebAuthnCredential, error)
GetWebAuthnCredentialByCredentialID(ctx context.Context, credentialID string) (*schemas.WebAuthnCredential, error)
ListWebAuthnCredentialsByUserID(ctx context.Context, userID string) ([]*schemas.WebAuthnCredential, error)
UpdateWebAuthnCredentialSignCount(ctx context.Context, credentialID string, signCount int64) error
UpdateWebAuthnCredentialLastUsed(ctx context.Context, credentialID string) error
DeleteWebAuthnCredential(ctx context.Context, id string) error
CountWebAuthnCredentialsByUserID(ctx context.Context, userID string) (int64, error)8. GraphQL API
type WebAuthnCredential {
id: ID!
name: String!
credential_id_prefix: String! # first 8 chars for identification
transport: [String!]
last_used_at: Int64
created_at: Int64!
}
type Query {
passkeys: [WebAuthnCredential!]! # List current user's passkeys
}
type Mutation {
rename_passkey(id: ID!, name: String!): WebAuthnCredential!
delete_passkey(id: ID!): Response!
}CLI Configuration Flags
--enable-passkeys=false # Enable WebAuthn/Passkeys
--passkey-mode=primary # primary | mfa | both
--max-passkeys-per-user=10 # Max registered passkeys
--webauthn-rp-name=Authorizer # Relying Party display name
--webauthn-rp-id= # Relying Party ID (defaults to host)
--webauthn-rp-origins= # Allowed origins for WebAuthn
Testing Plan
- Integration test: full registration flow (begin → finish → credential stored)
- Integration test: full login flow (begin → finish → session created)
- Test discoverable credentials (passkey login without email)
- Test sign count validation and clone detection
- Test max passkeys per user limit
- Test passkey deletion
- Test passkey as MFA second factor
- Test conditional UI (frontend E2E)