auth: publish passkey foundations preview
verify / verify (push) Successful in 3m40s

This commit is contained in:
2026-08-21 17:33:00 -04:00
parent fb6bbd0dad
commit bfe6cfd29e
230 changed files with 44547 additions and 17 deletions
+387
View File
@@ -0,0 +1,387 @@
package webauthn
import (
"bytes"
"context"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/go-webauthn/webauthn/protocol"
)
// LoginOption is a functional option that modifies the [protocol.PublicKeyCredentialRequestOptions] sent to the
// client during a login ceremony. Use the With* functions in this package (i.e. [WithUserVerification],
// [WithAllowedCredentials]) to create login options.
type LoginOption func(*protocol.PublicKeyCredentialRequestOptions)
// DiscoverableUserHandler is a callback function that the Relying Party must provide when performing a discoverable
// (passkey) login. It is called with the rawID of the credential and the userHandle from the authenticator response,
// and must return the [User] who owns the credential. This is necessary because in discoverable login flows, the
// Relying Party does not know which user is authenticating until the authenticator response is received.
type DiscoverableUserHandler func(rawID, userHandle []byte) (user User, err error)
// BeginLogin creates the [*protocol.CredentialAssertion] data payload that should be sent to the user agent for beginning
// the login/assertion process. This function is used to perform a login when the identity of the user is known such as
// multifactor authentications, to specify a conditional mediation requirement use [WebAuthn.BeginMediatedLogin], to
// perform a login when the identity of the user is not known see [WebAuthn.BeginDiscoverableLogin] and
// [WebAuthn.BeginDiscoverableMediatedLogin] instead. The format of this data can be seen in §5.5 of the WebAuthn
// specification. These default values can be amended by providing additional [LoginOption] parameters. This function
// also returns [SessionData], that must be stored by the RP in a secure manner and then provided to the
// [WebAuthn.FinishLogin] function. This data helps us verify the ownership of the credential being retrieved.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dictionary-assertion-options)
func (webauthn *WebAuthn) BeginLogin(user User, opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
return webauthn.BeginMediatedLogin(user, protocol.MediationDefault, opts...)
}
// BeginDiscoverableLogin creates the [*protocol.CredentialAssertion] data payload that should be sent to the user agent
// for beginning the login/assertion process. This function is used to perform a client-side discoverable login when the
// identity of the user is not known such as passwordless or usernameless authentication, to specify a conditional
// mediation requirement use [WebAuthn.BeginDiscoverableMediatedLogin], to perform logins where the identity of the user
// is known such as multifactor authentication see [WebAuthn.BeginLogin] and [WebAuthn.BeginMediatedLogin] instead.
// The format of this data can be seen in §5.5 of the WebAuthn specification. These default values can be amended by
// providing additional [LoginOption] parameters. This function also returns [SessionData], that
// must be stored by the RP in a secure manner and then provided to the [WebAuthn.FinishLogin] function. This data helps
// us verify the ownership of the credential being retrieved.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dictionary-assertion-options)
func (webauthn *WebAuthn) BeginDiscoverableLogin(opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
return webauthn.beginLogin(nil, nil, protocol.MediationDefault, opts...)
}
// BeginMediatedLogin is similar to [WebAuthn.BeginLogin] however it also allows specifying a credential mediation
// requirement.
func (webauthn *WebAuthn) BeginMediatedLogin(user User, mediation protocol.CredentialMediationRequirement, opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
credentials := user.WebAuthnCredentials()
if len(credentials) == 0 { // If the user does not have any credentials, we cannot perform an assertion.
return nil, nil, protocol.ErrBadRequest.WithDetails("Found no credentials for user")
}
var allowedCredentials = make([]protocol.CredentialDescriptor, len(credentials))
for i, credential := range credentials {
allowedCredentials[i] = credential.Descriptor()
}
return webauthn.beginLogin(user.WebAuthnID(), allowedCredentials, mediation, opts...)
}
// BeginDiscoverableMediatedLogin is similar to [WebAuthn.BeginDiscoverableLogin] however it also allows specifying a
// credential mediation requirement.
func (webauthn *WebAuthn) BeginDiscoverableMediatedLogin(mediation protocol.CredentialMediationRequirement, opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
return webauthn.beginLogin(nil, nil, mediation, opts...)
}
func (webauthn *WebAuthn) beginLogin(userID []byte, allowedCredentials []protocol.CredentialDescriptor, mediation protocol.CredentialMediationRequirement, opts ...LoginOption) (assertion *protocol.CredentialAssertion, session *SessionData, err error) {
if err = webauthn.Config.validate(); err != nil {
return nil, nil, fmt.Errorf(errFmtConfigValidate, err)
}
assertion = &protocol.CredentialAssertion{
Response: protocol.PublicKeyCredentialRequestOptions{
RelyingPartyID: webauthn.Config.RPID,
UserVerification: webauthn.Config.AuthenticatorSelection.UserVerification,
AllowedCredentials: allowedCredentials,
},
Mediation: mediation,
}
for _, opt := range opts {
opt(&assertion.Response)
}
if len(assertion.Response.Challenge) == 0 {
var challenge protocol.URLEncodedBase64
if challenge, err = protocol.CreateChallenge(); err != nil {
return nil, nil, err
}
assertion.Response.Challenge = challenge
}
if len(assertion.Response.Challenge) < protocol.MinimumChallengeLength {
return nil, nil, fmt.Errorf("error generating assertion: the challenge must be at least 16 bytes")
}
if len(assertion.Response.RelyingPartyID) == 0 {
return nil, nil, fmt.Errorf("error generating assertion: the relying party id must be provided via the configuration or a functional option for a login")
} else if err = protocol.ValidateRPID(assertion.Response.RelyingPartyID); err != nil {
return nil, nil, fmt.Errorf("error generating assertion: the relying party id failed to validate as it's not a valid domain string with error: %w", err)
}
if assertion.Response.Timeout == 0 {
switch assertion.Response.UserVerification {
case protocol.VerificationDiscouraged:
assertion.Response.Timeout = int(webauthn.Config.Timeouts.Login.TimeoutUVD.Milliseconds())
default:
assertion.Response.Timeout = int(webauthn.Config.Timeouts.Login.Timeout.Milliseconds())
}
}
session = &SessionData{
Challenge: assertion.Response.Challenge.String(),
RelyingPartyID: assertion.Response.RelyingPartyID,
UserID: userID,
AllowedCredentialIDs: assertion.Response.GetAllowedCredentialIDs(),
UserVerification: assertion.Response.UserVerification,
Extensions: assertion.Response.Extensions,
}
if webauthn.Config.Timeouts.Login.Enforce {
session.Expires = time.Now().Add(time.Millisecond * time.Duration(assertion.Response.Timeout))
}
return assertion, session, nil
}
// FinishLogin takes the response from the client and validates it against the user credentials and stored session data.
//
// As with all Finish functions, this function requires a [*http.Request] but you can perform the same steps with the
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
// [io.Reader] or byte array respectively, you can also use an arbitrary [*protocol.ParsedCredentialAssertionData] which is
// returned from all of these functions i.e. by implementing a custom parser. The [*SessionData],
// and [*protocol.ParsedCredentialAssertionData] can then be used with the [WebAuthn.ValidateLogin] function.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] provided does not contain
// a [Credential] with the same ID byte array provided all [Credential]'s in the [SessionData] exist in the [User]'s
// [Credential] list.
func (webauthn *WebAuthn) FinishLogin(user User, session SessionData, response *http.Request) (credential *Credential, err error) {
var parsedResponse *protocol.ParsedCredentialAssertionData
if parsedResponse, err = protocol.ParseCredentialRequestResponse(response); err != nil {
return nil, err
}
return webauthn.ValidateLogin(user, session, parsedResponse)
}
// FinishDiscoverableLogin takes the response from the client and validates it against the handler and stored session data.
// The handler helps to find out which user must be used to validate the response. This is a function defined in your
// business code that will retrieve the user from your persistent data.
//
// As with all Finish functions, this function requires a [*http.Request] but you can perform the same steps with the
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
// [io.Reader] or byte array respectively, you can also use an arbitrary [*protocol.ParsedCredentialAssertionData] which is
// returned from all of these functions i.e. by implementing a custom parser. The [DiscoverableUserHandler], [*SessionData],
// and [*protocol.ParsedCredentialAssertionData] can then be used with the [WebAuthn.ValidatePasskeyLogin] function.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
// in the [SessionData] exist in the [User]'s [Credential] list.
func (webauthn *WebAuthn) FinishDiscoverableLogin(handler DiscoverableUserHandler, session SessionData, response *http.Request) (credential *Credential, err error) {
var parsedResponse *protocol.ParsedCredentialAssertionData
if parsedResponse, err = protocol.ParseCredentialRequestResponse(response); err != nil {
return nil, err
}
return webauthn.ValidateDiscoverableLogin(handler, session, parsedResponse)
}
// FinishPasskeyLogin takes the response from the client and validate it against the handler and stored session data.
// The handler helps to find out which user must be used to validate the response. This is a function defined in your
// business code that will retrieve the user from your persistent data.
//
// As with all Finish functions this function requires a [*http.Request] but you can perform the same steps with the
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
// io.Reader or byte array respectively, you can also use an arbitrary [*protocol.ParsedCredentialAssertionData] which is
// returned from all of these functions i.e. by implementing a custom parser. The [DiscoverableUserHandler], [*SessionData],
// and [*protocol.ParsedCredentialAssertionData] can then be used with the [WebAuthn.ValidatePasskeyLogin] function.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
// in the [SessionData] exist in the [User]'s [Credential] list.
func (webauthn *WebAuthn) FinishPasskeyLogin(handler DiscoverableUserHandler, session SessionData, response *http.Request) (user User, credential *Credential, err error) {
var parsedResponse *protocol.ParsedCredentialAssertionData
if parsedResponse, err = protocol.ParseCredentialRequestResponse(response); err != nil {
return nil, nil, err
}
return webauthn.ValidatePasskeyLogin(handler, session, parsedResponse)
}
// ValidateLogin takes a parsed response and validates it against the user credentials and session data.
//
// If you wish to skip performing the step required to parse the *protocol.ParsedCredentialAssertionData and
// you're using net/http then you can use [WebAuthn.FinishLogin] instead.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] provided does not contain
// a [Credential] with the same ID byte array provided all [Credential]'s in the [SessionData] exist in
// the [User]'s [Credential] list.
func (webauthn *WebAuthn) ValidateLogin(user User, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (credential *Credential, err error) {
if !bytes.Equal(user.WebAuthnID(), session.UserID) {
return nil, protocol.ErrBadRequest.WithDetails("ID mismatch for User and Session")
}
if !session.Expires.IsZero() && session.Expires.Before(time.Now()) {
return nil, protocol.ErrBadRequest.WithDetails("Session has Expired")
}
return webauthn.validateLogin(user, session, parsedResponse)
}
// ValidateDiscoverableLogin is similar to [WebAuthn.ValidateLogin] that allows for discoverable credentials. It's
// recommended that [WebAuthn.ValidatePasskeyLogin] is used instead.
//
// If you wish to skip performing the step required to parse the [*protocol.ParsedCredentialAssertionData] and
// you're using net/http then you can use [WebAuthn.FinishDiscoverableLogin] instead.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
// in the [SessionData] exist in the [User]'s [Credential] list.
//
// Note: this is just a backwards compatibility layer over [WebAuthn.ValidatePasskeyLogin] which returns more information.
func (webauthn *WebAuthn) ValidateDiscoverableLogin(handler DiscoverableUserHandler, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (credential *Credential, err error) {
_, credential, err = webauthn.ValidatePasskeyLogin(handler, session, parsedResponse)
return credential, err
}
// ValidatePasskeyLogin is similar to [WebAuthn.ValidateLogin] that allows for discoverable credentials.
//
// If you wish to skip performing the step required to parse the [*protocol.ParsedCredentialAssertionData] and
// you're using net/http then you can use [WebAuthn.FinishPasskeyLogin] instead.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
// in the [SessionData] exist in the [User]'s [Credential] list.
func (webauthn *WebAuthn) ValidatePasskeyLogin(handler DiscoverableUserHandler, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (user User, credential *Credential, err error) {
if len(session.UserID) != 0 {
return nil, nil, protocol.ErrBadRequest.WithDetails("Session was not initiated as a client-side discoverable login")
}
if !session.Expires.IsZero() && session.Expires.Before(time.Now()) {
return nil, nil, protocol.ErrBadRequest.WithDetails("Session has Expired")
}
if len(parsedResponse.Response.UserHandle) == 0 {
return nil, nil, protocol.ErrBadRequest.WithDetails("Client-side Discoverable Assertion was attempted with a blank User Handle")
}
if user, err = handler(parsedResponse.RawID, parsedResponse.Response.UserHandle); err != nil {
return nil, nil, protocol.ErrBadRequest.WithDetails(fmt.Sprintf("Failed to lookup Client-side Discoverable Credential: %s", err)).WithError(err)
}
if user == nil {
return nil, nil, protocol.ErrBadRequest.WithDetails("Failed to lookup Client-side Discoverable Credential: handler returned a nil user")
}
if credential, err = webauthn.validateLogin(user, session, parsedResponse); err != nil {
return nil, nil, err
}
return user, credential, nil
}
// validateLogin takes a parsed response and validates it against the user credentials and session data.
//
//nolint:gocyclo
func (webauthn *WebAuthn) validateLogin(user User, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (*Credential, error) {
// Step 1. If the allowCredentials option was given when this authentication ceremony was initiated,
// verify that credential.id identifies one of the public key credentials that were listed in
// allowCredentials.
// NON-NORMATIVE Prior Step: Verify that the allowCredentials for the session are owned by the user provided.
credentials := user.WebAuthnCredentials()
if len(session.AllowedCredentialIDs) > 0 {
if !isCredentialsAllowedMatchingOwned(session.AllowedCredentialIDs, credentials) {
return nil, protocol.ErrBadRequest.WithDetails("User does not own all credentials from the allowed credential list")
}
if !isCredentialIDInCredentials(parsedResponse.RawID, credentials) {
return nil, &protocol.ErrorUnknownCredential{Err: protocol.ErrBadRequest.WithDetails("The credential ID provided is not owned by the user")}
}
if !isByteArrayInSlice(parsedResponse.RawID, session.AllowedCredentialIDs...) {
return nil, protocol.ErrBadRequest.WithDetails("The credential ID provided is not in the sessions allowed credential list")
}
}
// Step 2. If credential.response.userHandle is present, verify that the user identified by this value is
// the owner of the public key credential identified by credential.id. This is in part handled by our Step 1.
userHandle := parsedResponse.Response.UserHandle
if len(userHandle) > 0 {
if !bytes.Equal(userHandle, user.WebAuthnID()) {
return nil, protocol.ErrBadRequest.WithDetails("User handle and User ID do not match")
}
}
var (
found bool
credential Credential
)
// Step 3. Using credentials id attribute (or the corresponding rawId, if base64url encoding is inappropriate
// for your use case), look up the corresponding credential public key.
for _, credential = range credentials {
if bytes.Equal(credential.ID, parsedResponse.RawID) {
found = true
break
}
}
if !found {
return nil, protocol.ErrBadRequest.WithDetails("Unable to find the credential for the returned credential ID")
}
var (
appID string
err error
)
// Ensure authenticators with a bad status are not used.
if webauthn.Config.MDS != nil {
var aaguid uuid.UUID
if len(credential.Authenticator.AAGUID) == 0 {
aaguid = uuid.Nil
} else if aaguid, err = uuid.FromBytes(credential.Authenticator.AAGUID); err != nil {
return nil, protocol.ErrBadRequest.WithDetails("Failed to decode AAGUID").WithInfo(fmt.Sprintf("Error occurred decoding AAGUID from the credential record: %s", err)).WithError(err)
}
if e := protocol.ValidateMetadata(context.Background(), webauthn.Config.MDS, aaguid, credential.AttestationType, credential.AttestationFormat, nil); e != nil {
return nil, protocol.ErrBadRequest.WithDetails("Failed to validate credential record metadata").WithInfo(e.DevInfo).WithError(e)
}
}
shouldVerifyUser := session.UserVerification == protocol.VerificationRequired
shouldVerifyUserPresence := true
rpID := webauthn.Config.RPID
rpOrigins := webauthn.Config.RPOrigins
rpTopOrigins := webauthn.Config.RPTopOrigins
if appID, err = parsedResponse.GetAppID(session.Extensions, credential.AttestationFormat); err != nil {
return nil, err
}
// Handle steps 4 through 16.
if err = parsedResponse.Verify(session.Challenge, rpID, appID, rpOrigins, rpTopOrigins, webauthn.Config.RPTopOriginVerificationMode, webauthn.Config.RPAllowCrossOrigin, shouldVerifyUser, shouldVerifyUserPresence, credential.PublicKey); err != nil {
return nil, err
}
// Check if the BackupEligible flag has changed.
if credential.Flags.BackupEligible != parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() {
return nil, protocol.ErrBadRequest.WithDetails("Backup Eligible flag inconsistency detected during login validation")
}
// Check for the invalid combination BE=0 and BS=1.
if !parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() && parsedResponse.Response.AuthenticatorData.Flags.HasBackupState() {
return nil, protocol.ErrBadRequest.WithDetails("Backup State Flag is true but Backup Eligible flag is false which is invalid")
}
// Handle step 17.
credential.Authenticator.UpdateCounter(parsedResponse.Response.AuthenticatorData.Counter)
// Update flags from response data.
credential.Flags = NewCredentialFlags(parsedResponse.Response.AuthenticatorData.Flags)
return &credential, nil
}