Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions passkey/doc.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Package passkey verifies WebAuthn passkey ceremonies for explicit authkit exchange flows.
//
// The package owns relying-party ceremony mechanics and credential state, but it
// does not provide HTTP handlers, browser session management, CSRF protection, or
// UI flows. Consumers should use the returned WebAuthn options and session data
// in their own transport layer, then exchange verified identities through
// authkit's onboarding or exchange packages.
// does not provide HTTP handlers, CSRF protection, or UI flows. Consumers should
// use the returned WebAuthn options and session data in their own transport
// layer, then exchange verified identities through authkit's onboarding or
// exchange packages. For reusable server-side session storage between begin and
// finish calls, see github.com/meigma/authkit/passkey/session.
package passkey
118 changes: 118 additions & 0 deletions passkey/session/clone.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package session

import (
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"

"github.com/meigma/authkit/passkey"
)

func cloneRegistration(registration Registration) Registration {
return Registration{
User: cloneUser(registration.User),
SessionData: cloneSessionData(registration.SessionData),
}
}

func cloneLogin(login Login) Login {
return Login{
SessionData: cloneSessionData(login.SessionData),
}
}

func cloneUser(user passkey.User) passkey.User {
return passkey.User{
RPID: user.RPID,
PrincipalID: user.PrincipalID,
Handle: cloneBytes(user.Handle),
Name: user.Name,
DisplayName: user.DisplayName,
}
}

func cloneSessionData(data webauthn.SessionData) webauthn.SessionData {
clone := data
clone.UserID = cloneBytes(data.UserID)
clone.AllowedCredentialIDs = cloneByteSlices(data.AllowedCredentialIDs)
clone.CredParams = cloneCredentialParameters(data.CredParams)
clone.Extensions = cloneAuthenticationExtensions(data.Extensions)

return clone
}

func cloneCredentialParameters(params []protocol.CredentialParameter) []protocol.CredentialParameter {
if len(params) == 0 {
return nil
}

clone := make([]protocol.CredentialParameter, len(params))
copy(clone, params)

return clone
}

func cloneAuthenticationExtensions(
extensions protocol.AuthenticationExtensions,
) protocol.AuthenticationExtensions {
if len(extensions) == 0 {
return nil
}

clone := make(protocol.AuthenticationExtensions, len(extensions))
for name, value := range extensions {
clone[name] = cloneExtensionValue(value)
}

return clone
}

func cloneExtensionValue(value any) any {
switch typed := value.(type) {
case []byte:
return cloneBytes(typed)
case [][]byte:
return cloneByteSlices(typed)
case []any:
clone := make([]any, len(typed))
for i, item := range typed {
clone[i] = cloneExtensionValue(item)
}

return clone
case map[string]any:
clone := make(map[string]any, len(typed))
for key, item := range typed {
clone[key] = cloneExtensionValue(item)
}

return clone
case protocol.AuthenticationExtensions:
return cloneAuthenticationExtensions(typed)
default:
return value
}
}

func cloneBytes(value []byte) []byte {
if len(value) == 0 {
return nil
}

clone := make([]byte, len(value))
copy(clone, value)

return clone
}

func cloneByteSlices(values [][]byte) [][]byte {
if len(values) == 0 {
return nil
}

clone := make([][]byte, len(values))
for i, value := range values {
clone[i] = cloneBytes(value)
}

return clone
}
13 changes: 13 additions & 0 deletions passkey/session/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Package session stores server-side WebAuthn passkey session data between
// begin and finish calls.
//
// The package intentionally does not provide HTTP handlers, cookies, CSRF
// protection, or response helpers. It only preserves the WebAuthn session data
// and passkey user values that consumers must pass back into the passkey
// package after the browser returns a registration or login response.
//
// MemoryStore is process-local storage. It is suitable for single-process
// applications and tests, but callers still need their own request throttling
// and should provide a distributed Store implementation when sessions must
// survive process restarts or multiple application instances.
package session
11 changes: 11 additions & 0 deletions passkey/session/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package session

import "errors"

var (
// ErrNotFound indicates that a passkey session ID is unknown or belongs to a different flow.
ErrNotFound = errors.New("passkey/session: session not found")

// ErrExpired indicates that a passkey session expired or has no expiration.
ErrExpired = errors.New("passkey/session: session expired")
)
174 changes: 174 additions & 0 deletions passkey/session/memory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package session

import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"sync"
"time"
)

const (
sessionIDBytes = 32
maxIDAttempts = 4
)

type sessionKind string

const (
sessionKindRegistration sessionKind = "registration"
sessionKindLogin sessionKind = "login"
)

// MemoryStore stores passkey sessions in process memory.
type MemoryStore struct {
mu sync.Mutex
clock func() time.Time
sessions map[string]memorySession
}

type memorySession struct {
kind sessionKind
registration Registration
login Login
expiresAt time.Time
}

// NewMemoryStore constructs an empty in-memory passkey session store.
func NewMemoryStore(opts ...MemoryOption) *MemoryStore {
cfg := defaultMemoryOptions()
for _, opt := range opts {
if opt != nil {
opt(&cfg)
}
}

return &MemoryStore{
clock: cfg.clock,
sessions: make(map[string]memorySession),
}
}

// PutRegistration stores registration and returns an opaque session ID.
func (s *MemoryStore) PutRegistration(ctx context.Context, registration Registration) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if registration.SessionData.Expires.IsZero() {
return "", ErrExpired
}

return s.put(memorySession{
kind: sessionKindRegistration,
registration: cloneRegistration(registration),
expiresAt: registration.SessionData.Expires,
})
}

// TakeRegistration returns and removes the registration session identified by id.
func (s *MemoryStore) TakeRegistration(ctx context.Context, id string) (Registration, error) {
if err := ctx.Err(); err != nil {
return Registration{}, err
}

session, err := s.take(id, sessionKindRegistration)
if err != nil {
return Registration{}, err
}

return cloneRegistration(session.registration), nil
}

// PutLogin stores login and returns an opaque session ID.
func (s *MemoryStore) PutLogin(ctx context.Context, login Login) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
if login.SessionData.Expires.IsZero() {
return "", ErrExpired
}

return s.put(memorySession{
kind: sessionKindLogin,
login: cloneLogin(login),
expiresAt: login.SessionData.Expires,
})
}

// TakeLogin returns and removes the login session identified by id.
func (s *MemoryStore) TakeLogin(ctx context.Context, id string) (Login, error) {
if err := ctx.Err(); err != nil {
return Login{}, err
}

session, err := s.take(id, sessionKindLogin)
if err != nil {
return Login{}, err
}

return cloneLogin(session.login), nil
}

func (s *MemoryStore) put(session memorySession) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()

s.pruneExpiredLocked(s.clock())
for range maxIDAttempts {
id, err := newSessionID()
if err != nil {
return "", err
}
if _, exists := s.sessions[id]; exists {
continue
}

s.sessions[id] = session

return id, nil
}

return "", errors.New("passkey/session: generate unique session ID")
}

func (s *MemoryStore) take(id string, kind sessionKind) (memorySession, error) {
s.mu.Lock()
defer s.mu.Unlock()

session, exists := s.sessions[id]
if !exists {
return memorySession{}, ErrNotFound
}
delete(s.sessions, id)

if session.kind != kind {
return memorySession{}, ErrNotFound
}
if sessionExpired(session.expiresAt, s.clock()) {
return memorySession{}, ErrExpired
}

return session, nil
}

func (s *MemoryStore) pruneExpiredLocked(now time.Time) {
for id, session := range s.sessions {
if sessionExpired(session.expiresAt, now) {
delete(s.sessions, id)
}
}
}

func sessionExpired(expiresAt time.Time, now time.Time) bool {
return expiresAt.IsZero() || !expiresAt.After(now)
}

func newSessionID() (string, error) {
raw := make([]byte, sessionIDBytes)
if _, err := rand.Read(raw); err != nil {
return "", err
}

return base64.RawURLEncoding.EncodeToString(raw), nil
}
Loading