From d34efd29ec7e283c845cdd00990f1d0db9977aff842369297f820447c731930a Mon Sep 17 00:00:00 2001 From: Alexandra Knopf Date: Mon, 3 Aug 2026 06:04:02 -0700 Subject: [PATCH] add basic passkey template --- go.mod | 19 +++ go.sum | 41 +++++ main.go | 339 +++++++++++++++++++++++++++++++++++++++ models.go | 43 +++++ store.go | 73 +++++++++ web/index.es5.umd.min.js | 306 +++++++++++++++++++++++++++++++++++ web/index.html | 35 ++++ web/script.js | 97 +++++++++++ 8 files changed, 953 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 models.go create mode 100644 store.go create mode 100644 web/index.es5.umd.min.js create mode 100644 web/index.html create mode 100644 web/script.js diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8837c08 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module OwOrganizer + +go 1.21.7 + +require ( + github.com/go-webauthn/webauthn v0.10.2 + github.com/google/uuid v1.6.0 +) + +require ( + github.com/fxamacker/cbor/v2 v2.6.0 // indirect + github.com/go-webauthn/x v0.1.9 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/google/go-tpm v0.9.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/sys v0.18.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5889d2c --- /dev/null +++ b/go.sum @@ -0,0 +1,41 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.5.0 h1:oHsG0V/Q6E/wqTS2O1Cozzsy69nqCiguo5Q1a1ADivE= +github.com/fxamacker/cbor/v2 v2.5.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA= +github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-webauthn/webauthn v0.10.1 h1:+RFKj4yHPy282teiiy5sqTYPfRilzBpJyedrz9KsNFE= +github.com/go-webauthn/webauthn v0.10.1/go.mod h1:a7BwAtrSMkeuJXtIKz433Av99nAv01pdfzB0a9xkDnI= +github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4= +github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs= +github.com/go-webauthn/x v0.1.8 h1:f1C6k1AyUlDvnIzWSW+G9rN9nbp1hhLXZagUtyxZ8nc= +github.com/go-webauthn/x v0.1.8/go.mod h1:i8UNlGVt3oy6oAFcP4SZB1djZLx/4pbekCbWowjTaJg= +github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE= +github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk= +github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..2a79fe3 --- /dev/null +++ b/main.go @@ -0,0 +1,339 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +var ( + webAuthn *webauthn.WebAuthn + err error + + datastore PasskeyStore + //sessions SessionStore + l Logger +) + +type Logger interface { + Printf(format string, v ...interface{}) +} + +type PasskeyUser interface { + webauthn.User + AddCredential(*webauthn.Credential) + UpdateCredential(*webauthn.Credential) +} + +type PasskeyStore interface { + GetOrCreateUser(userName string) PasskeyUser + SaveUser(PasskeyUser) + GenSessionID() (string, error) + GetSession(token string) (webauthn.SessionData, bool) + SaveSession(token string, data webauthn.SessionData) + DeleteSession(token string) +} + +func main() { + l = log.Default() + + proto := getEnv("PROTO", "http") + host := getEnv("HOST", "localhost") + port := getEnv("PORT", ":8080") + origin := fmt.Sprintf("%s://%s%s", proto, host, port) + + l.Printf("[INFO] make webauthn config") + wconfig := &webauthn.Config{ + RPDisplayName: "Go Webauthn", // Display Name for your site + RPID: host, // Generally the FQDN for your site + RPOrigins: []string{origin}, // The origin URLs allowed for WebAuthn + } + + l.Printf("[INFO] create webauthn") + if webAuthn, err = webauthn.New(wconfig); err != nil { + fmt.Printf("[FATA] %s", err.Error()) + os.Exit(1) + } + + l.Printf("[INFO] create datastore") + datastore = NewInMem(l) + + l.Printf("[INFO] register routes") + // Serve the web files + http.Handle("/", http.FileServer(http.Dir("./web"))) + + // Add auth the routes + http.HandleFunc("/api/passkey/registerStart", BeginRegistration) + http.HandleFunc("/api/passkey/registerFinish", FinishRegistration) + http.HandleFunc("/api/passkey/loginStart", BeginLogin) + http.HandleFunc("/api/passkey/loginFinish", FinishLogin) + + http.Handle("/private", LoggedInMiddleware(http.HandlerFunc(PrivatePage))) + + // Start the server + l.Printf("[INFO] start server at %s", origin) + if err := http.ListenAndServe(port, nil); err != nil { + fmt.Println(err) + } +} + +func BeginRegistration(w http.ResponseWriter, r *http.Request) { + l.Printf("[INFO] begin registration ----------------------\\") + + // TODO: i don't like this, but it's a quick solution + // can we actually do not use the username at all? + username, err := getUsername(r) + if err != nil { + l.Printf("[ERRO] can't get user name: %s", err.Error()) + + panic(err) // FIXME: handle error + } + + user := datastore.GetOrCreateUser(username) // Find or create the new user + + options, session, err := webAuthn.BeginRegistration(user) + if err != nil { + msg := fmt.Sprintf("can't begin registration: %s", err.Error()) + l.Printf("[ERRO] %s", msg) + JSONResponse(w, msg, http.StatusBadRequest) + + return + } + + // Make a session key and store the sessionData values + t, err := datastore.GenSessionID() + if err != nil { + l.Printf("[ERRO] can't generate session id: %s", err.Error()) + + panic(err) // FIXME: handle error + } + + datastore.SaveSession(t, *session) + + http.SetCookie(w, &http.Cookie{ + Name: "sid", + Value: t, + Path: "api/passkey/registerStart", + MaxAge: 3600, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, // TODO: SameSiteStrictMode maybe? + }) + + JSONResponse(w, options, http.StatusOK) // return the options generated with the session key + // options.publicKey contain our registration options +} + +func FinishRegistration(w http.ResponseWriter, r *http.Request) { + // Get the session key from cookie + sid, err := r.Cookie("sid") + if err != nil { + l.Printf("[ERRO] can't get session id: %s", err.Error()) + + panic(err) // FIXME: handle error + } + + // Get the session data stored from the function above + session, _ := datastore.GetSession(sid.Value) // FIXME: cover invalid session + + // In out example username == userID, but in real world it should be different + user := datastore.GetOrCreateUser(string(session.UserID)) // Get the user + + credential, err := webAuthn.FinishRegistration(user, session, r) + if err != nil { + msg := fmt.Sprintf("can't finish registration: %s", err.Error()) + l.Printf("[ERRO] %s", msg) + // clean up sid cookie + http.SetCookie(w, &http.Cookie{ + Name: "sid", + Value: "", + }) + JSONResponse(w, msg, http.StatusBadRequest) + + return + } + + // If creation was successful, store the credential object + user.AddCredential(credential) + datastore.SaveUser(user) + // Delete the session data + datastore.DeleteSession(sid.Value) + http.SetCookie(w, &http.Cookie{ + Name: "sid", + Value: "", + }) + + l.Printf("[INFO] finish registration ----------------------/") + JSONResponse(w, "Registration Success", http.StatusOK) // Handle next steps +} + +func BeginLogin(w http.ResponseWriter, r *http.Request) { + l.Printf("[INFO] begin login ----------------------\\") + + username, err := getUsername(r) + if err != nil { + l.Printf("[ERRO]can't get user name: %s", err.Error()) + panic(err) + } + + user := datastore.GetOrCreateUser(username) // Find the user + + options, session, err := webAuthn.BeginLogin(user) + if err != nil { + msg := fmt.Sprintf("can't begin login: %s", err.Error()) + l.Printf("[ERRO] %s", msg) + JSONResponse(w, msg, http.StatusBadRequest) + + return + } + + // Make a session key and store the sessionData values + t, err := datastore.GenSessionID() + if err != nil { + l.Printf("[ERRO] can't generate session id: %s", err.Error()) + + panic(err) // TODO: handle error + } + datastore.SaveSession(t, *session) + + http.SetCookie(w, &http.Cookie{ + Name: "sid", + Value: t, + Path: "api/passkey/loginStart", + MaxAge: 3600, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, // TODO: SameSiteStrictMode maybe? + }) + + JSONResponse(w, options, http.StatusOK) // return the options generated with the session key + // options.publicKey contain our registration options +} + +func FinishLogin(w http.ResponseWriter, r *http.Request) { + // Get the session key from cookie + sid, err := r.Cookie("sid") + if err != nil { + l.Printf("[ERRO] can't get session id: %s", err.Error()) + + panic(err) // FIXME: handle error + } + // Get the session data stored from the function above + session, _ := datastore.GetSession(sid.Value) // FIXME: cover invalid session + + // In out example username == userID, but in real world it should be different + user := datastore.GetOrCreateUser(string(session.UserID)) // Get the user + + credential, err := webAuthn.FinishLogin(user, session, r) + if err != nil { + l.Printf("[ERRO] can't finish login: %s", err.Error()) + panic(err) + } + + // Handle credential.Authenticator.CloneWarning + if credential.Authenticator.CloneWarning { + l.Printf("[WARN] can't finish login: %s", "CloneWarning") + } + + // If login was successful, update the credential object + user.UpdateCredential(credential) + datastore.SaveUser(user) + + // Delete the login session data + datastore.DeleteSession(sid.Value) + http.SetCookie(w, &http.Cookie{ + Name: "sid", + Value: "", + }) + + // Add the new session cookie + t, err := datastore.GenSessionID() + if err != nil { + l.Printf("[ERRO] can't generate session id: %s", err.Error()) + + panic(err) // TODO: handle error + } + + datastore.SaveSession(t, webauthn.SessionData{ + Expires: time.Now().Add(time.Hour), + }) + http.SetCookie(w, &http.Cookie{ + Name: "sid", + Value: t, + Path: "/", + MaxAge: 3600, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, // TODO: SameSiteStrictMode maybe? + }) + + l.Printf("[INFO] finish login ----------------------/") + JSONResponse(w, "Login Success", http.StatusOK) +} + +func PrivatePage(w http.ResponseWriter, r *http.Request) { + // just show "Hello, World!" for now + _, _ = w.Write([]byte("Hello, World!")) +} + +// JSONResponse is a helper function to send json response +func JSONResponse(w http.ResponseWriter, data interface{}, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +// getUsername is a helper function to extract the username from json request +func getUsername(r *http.Request) (string, error) { + type Username struct { + Username string `json:"username"` + } + var u Username + if err := json.NewDecoder(r.Body).Decode(&u); err != nil { + return "", err + } + + return u.Username, nil +} + +// getEnv is a helper function to get the environment variable +func getEnv(key, def string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + + return def +} + +func LoggedInMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // TODO: url to redirect to should be passed as a parameter + + sid, err := r.Cookie("sid") + if err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + + return + } + + session, ok := datastore.GetSession(sid.Value) + if !ok { + http.Redirect(w, r, "/", http.StatusSeeOther) + + return + } + + if session.Expires.Before(time.Now()) { + http.Redirect(w, r, "/", http.StatusSeeOther) + + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..27da5a5 --- /dev/null +++ b/models.go @@ -0,0 +1,43 @@ +package main + +import "github.com/go-webauthn/webauthn/webauthn" + +type User struct { + ID []byte + DisplayName string + Name string + + creds []webauthn.Credential +} + +func (o *User) WebAuthnID() []byte { + return o.ID +} + +func (o *User) WebAuthnName() string { + return o.Name +} + +func (o *User) WebAuthnDisplayName() string { + return o.DisplayName +} + +func (o *User) WebAuthnIcon() string { + return "https://pics.com/avatar.png" +} + +func (o *User) WebAuthnCredentials() []webauthn.Credential { + return o.creds +} + +func (o *User) AddCredential(credential *webauthn.Credential) { + o.creds = append(o.creds, *credential) +} + +func (o *User) UpdateCredential(credential *webauthn.Credential) { + for i, c := range o.creds { + if string(c.ID) == string(credential.ID) { + o.creds[i] = *credential + } + } +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..e6a3a5d --- /dev/null +++ b/store.go @@ -0,0 +1,73 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + + "github.com/go-webauthn/webauthn/webauthn" +) + +type InMem struct { + // TODO: it would be nice to have a mutex here + // TODO: use pointers to avoid copying + users map[string]PasskeyUser + sessions map[string]webauthn.SessionData + + log Logger +} + +func (i *InMem) GenSessionID() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + + return base64.URLEncoding.EncodeToString(b), nil + +} + +func NewInMem(log Logger) *InMem { + return &InMem{ + users: make(map[string]PasskeyUser), + sessions: make(map[string]webauthn.SessionData), + log: log, + } +} + +func (i *InMem) GetSession(token string) (webauthn.SessionData, bool) { + i.log.Printf("[DEBUG] GetSession: %v", i.sessions[token]) + val, ok := i.sessions[token] + + return val, ok +} + +func (i *InMem) SaveSession(token string, data webauthn.SessionData) { + i.log.Printf("[DEBUG] SaveSession: %s - %v", token, data) + i.sessions[token] = data +} + +func (i *InMem) DeleteSession(token string) { + i.log.Printf("[DEBUG] DeleteSession: %v", token) + delete(i.sessions, token) +} + +func (i *InMem) GetOrCreateUser(userName string) PasskeyUser { + i.log.Printf("[DEBUG] GetOrCreateUser: %v", userName) + if _, ok := i.users[userName]; !ok { + i.log.Printf("[DEBUG] GetOrCreateUser: creating new user: %v", userName) + i.users[userName] = &User{ + ID: []byte(userName), + DisplayName: userName, + Name: userName, + } + } + + return i.users[userName] +} + +func (i *InMem) SaveUser(user PasskeyUser) { + i.log.Printf("[DEBUG] SaveUser: %v", user.WebAuthnName()) + i.log.Printf("[DEBUG] SaveUser: %v", user) + i.users[user.WebAuthnName()] = user +} diff --git a/web/index.es5.umd.min.js b/web/index.es5.umd.min.js new file mode 100644 index 0000000..af52c34 --- /dev/null +++ b/web/index.es5.umd.min.js @@ -0,0 +1,306 @@ +/* [@simplewebauthn/browser@10.0.0] */ +!function(e, t) { + "object" == typeof exports && "undefined" != typeof module ? t(exports) + : "function" == typeof define && define.amd + ? define([ "exports" ], t) + : t((e = "undefined" != typeof globalThis ? globalThis : e || self) + .SimpleWebAuthnBrowser = {}) +}(this, (function(e) { + "use strict"; + function t(e) { + const t = new Uint8Array(e); + let r = ""; + for (const e of t) + r += String.fromCharCode(e); + return btoa(r).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") + } + function r(e) { + const t = e.replace(/-/g, "+").replace(/_/g, "/"), + r = (4 - t.length % 4) % 4, n = t.padEnd(t.length + r, "="), + o = atob(n), i = new ArrayBuffer(o.length), a = new Uint8Array(i); + for (let e = 0; e < o.length; e++) + a[e] = o.charCodeAt(e); + return i + } + function n() { + return void 0 !== window?.PublicKeyCredential && + "function" == typeof window.PublicKeyCredential + } + function o(e) { + const {id : t} = e; + return { ...e, id: r(t), transports: e.transports } + } + function i(e) { + return "localhost" === e || + /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(e) + } + class a extends Error { + constructor({message : e, code: t, cause: r, name: n}) { + super(e, {cause : r}), this.name = n ?? r.name, this.code = t + } + } + const s = new class { + createNewAbortSignal() { + if (this.controller) { + const e = + new Error("Cancelling existing WebAuthn API call for new one"); + e.name = "AbortError", this.controller.abort(e) + } + const e = new AbortController; + return this.controller = e, e.signal + } + cancelCeremony() { + if (this.controller) { + const e = new Error("Manually cancelling existing WebAuthn API call"); + e.name = "AbortError", this.controller.abort(e), + this.controller = void 0 + } + } + } + , c = [ "cross-platform", "platform" ]; + function l(e) { + if (e && !(c.indexOf(e) < 0)) + return e + } + function u(e, t) { + console.warn( + `The browser extension that intercepted this WebAuthn API call incorrectly implemented ${ + e}. You should report this error to them.\n`, + t) + } + function d() { + if (!n()) + return new Promise((e => e(!1))); + const e = window.PublicKeyCredential; + return void 0 === e.isConditionalMediationAvailable + ? new Promise((e => e(!1))) + : e.isConditionalMediationAvailable() + } + e.WebAuthnAbortService = s, e.WebAuthnError = a, + e.base64URLStringToBuffer = r, e.browserSupportsWebAuthn = n, + e.browserSupportsWebAuthnAutofill = d, e.bufferToBase64URLString = t, + e.platformAuthenticatorIsAvailable = function() { + return n() ? PublicKeyCredential + .isUserVerifyingPlatformAuthenticatorAvailable() + : new Promise((e => e(!1))) + }, e.startAuthentication = async function(e, c = !1) { + if (!n()) + throw new Error("WebAuthn is not supported in this browser"); + let u; + 0 !== e.allowCredentials?.length && (u = e.allowCredentials?.map(o)); + const h = {...e, challenge : r(e.challenge), allowCredentials : u}, + f = {}; + if (c) { + if (!await d()) + throw Error("Browser does not support WebAuthn autofill"); + if (document.querySelectorAll("input[autocomplete$='webauthn']") + .length < 1) + throw Error( + 'No with "webauthn" as the only or last value in its `autocomplete` attribute was detected'); + f.mediation = "conditional", h.allowCredentials = [] + } + let p; + f.publicKey = h, f.signal = s.createNewAbortSignal(); + try { + p = await navigator.credentials.get(f) + } catch (e) { + throw function({error : e, options : t}) { + const {publicKey : r} = t; + if (!r) + throw Error("options was missing required publicKey property"); + if ("AbortError" === e.name) { + if (t.signal instanceof AbortSignal) + return new a({ + message : "Authentication ceremony was sent an abort signal", + code : "ERROR_CEREMONY_ABORTED", + cause : e + }) + } else { + if ("NotAllowedError" === e.name) + return new a({ + message : e.message, + code : "ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY", + cause : e + }); + if ("SecurityError" === e.name) { + const t = window.location.hostname; + if (!i(t)) + return new a({ + message : `${window.location.hostname} is an invalid domain`, + code : "ERROR_INVALID_DOMAIN", + cause : e + }); + if (r.rpId !== t) + return new a({ + message : `The RP ID "${r.rpId}" is invalid for this domain`, + code : "ERROR_INVALID_RP_ID", + cause : e + }) + } else if ("UnknownError" === e.name) + return new a({ + message : + "The authenticator was unable to process the specified options, or could not create a new assertion signature", + code : "ERROR_AUTHENTICATOR_GENERAL_ERROR", + cause : e + }) + } + return e + }({error : e, options : f}) + } + if (!p) + throw new Error("Authentication was not completed"); + const {id : R, rawId : w, response : E, type : g} = p; + let A; + return E.userHandle && (A = t(E.userHandle)), { + id: R, rawId: t(w), response: { + authenticatorData: t(E.authenticatorData), + clientDataJSON: t(E.clientDataJSON), + signature: t(E.signature), + userHandle: A + }, + type: g, clientExtensionResults: p.getClientExtensionResults(), + authenticatorAttachment: l(p.authenticatorAttachment) + } + }, e.startRegistration = async function(e) { + if (!n()) + throw new Error("WebAuthn is not supported in this browser"); + const c = { + publicKey : { + ...e, + challenge : r(e.challenge), + user : {...e.user, id : r(e.user.id)}, + excludeCredentials : e.excludeCredentials?.map(o) + } + }; + let d; + c.signal = s.createNewAbortSignal(); + try { + d = await navigator.credentials.create(c) + } catch (e) { + throw function({error : e, options : t}) { + const {publicKey : r} = t; + if (!r) + throw Error("options was missing required publicKey property"); + if ("AbortError" === e.name) { + if (t.signal instanceof AbortSignal) + return new a({ + message : "Registration ceremony was sent an abort signal", + code : "ERROR_CEREMONY_ABORTED", + cause : e + }) + } else if ("ConstraintError" === e.name) { + if (!0 === r.authenticatorSelection?.requireResidentKey) + return new a({ + message : + "Discoverable credentials were required but no available authenticator supported it", + code : + "ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT", + cause : e + }); + if ("required" === r.authenticatorSelection?.userVerification) + return new a({ + message : + "User verification was required but no available authenticator supported it", + code : "ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT", + cause : e + }) + } else { + if ("InvalidStateError" === e.name) + return new a({ + message : "The authenticator was previously registered", + code : "ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED", + cause : e + }); + if ("NotAllowedError" === e.name) + return new a({ + message : e.message, + code : "ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY", + cause : e + }); + if ("NotSupportedError" === e.name) + return 0 === r.pubKeyCredParams + .filter((e => "public-key" === e.type)) + .length + ? new a({ + message : + 'No entry in pubKeyCredParams was of type "public-key"', + code : "ERROR_MALFORMED_PUBKEYCREDPARAMS", + cause : e + }) + : new a({ + message : + "No available authenticator supported any of the specified pubKeyCredParams algorithms", + code : + "ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG", + cause : e + }); + if ("SecurityError" === e.name) { + const t = window.location.hostname; + if (!i(t)) + return new a({ + message : `${window.location.hostname} is an invalid domain`, + code : "ERROR_INVALID_DOMAIN", + cause : e + }); + if (r.rp.id !== t) + return new a({ + message : `The RP ID "${r.rp.id}" is invalid for this domain`, + code : "ERROR_INVALID_RP_ID", + cause : e + }) + } else if ("TypeError" === e.name) { + if (r.user.id.byteLength < 1 || r.user.id.byteLength > 64) + return new a({ + message : "User ID was not between 1 and 64 characters", + code : "ERROR_INVALID_USER_ID_LENGTH", + cause : e + }) + } else if ("UnknownError" === e.name) + return new a({ + message : + "The authenticator was unable to process the specified options, or could not create a new credential", + code : "ERROR_AUTHENTICATOR_GENERAL_ERROR", + cause : e + }) + } + return e + }({error : e, options : c}) + } + if (!d) + throw new Error("Registration was not completed"); + const {id : h, rawId : f, response : p, type : R} = d; + let w, E, g, A; + if ("function" == typeof p.getTransports && (w = p.getTransports()), + "function" == typeof p.getPublicKeyAlgorithm) + try { + E = p.getPublicKeyAlgorithm() + } catch (e) { + u("getPublicKeyAlgorithm()", e) + } + if ("function" == typeof p.getPublicKey) + try { + const e = p.getPublicKey(); + null !== e && (g = t(e)) + } catch (e) { + u("getPublicKey()", e) + } + if ("function" == typeof p.getAuthenticatorData) + try { + A = t(p.getAuthenticatorData()) + } catch (e) { + u("getAuthenticatorData()", e) + } + return { + id: h, rawId: t(f), response: { + attestationObject: t(p.attestationObject), + clientDataJSON: t(p.clientDataJSON), + transports: w, + publicKeyAlgorithm: E, + publicKey: g, + authenticatorData: A + }, + type: R, clientExtensionResults: d.getClientExtensionResults(), + authenticatorAttachment: l(d.authenticatorAttachment) + } + }, Object.defineProperty(e, "__esModule", {value : !0}) + })); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..91c26da --- /dev/null +++ b/web/index.html @@ -0,0 +1,35 @@ + + + + + + Passkey + + + + +
+
+

🔑 Passkey

+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ PRIVATE +
+ + + + + \ No newline at end of file diff --git a/web/script.js b/web/script.js new file mode 100644 index 0000000..3bb20ad --- /dev/null +++ b/web/script.js @@ -0,0 +1,97 @@ +document.getElementById('registerButton').addEventListener('click', register); +document.getElementById('loginButton').addEventListener('click', login); + + +function showMessage(message, isError = false) { + const messageElement = document.getElementById('message'); + messageElement.textContent = message; + messageElement.style.color = isError ? 'red' : 'green'; +} + +async function register() { + // Retrieve the username from the input field + const username = document.getElementById('username').value; + + try { + // Get registration options from your server. Here, we also receive the challenge. + const response = await fetch('/api/passkey/registerStart', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username: username}) + }); + + // Check if the registration options are ok. + if (!response.ok) { + const msg = await response.json(); + throw new Error('User already exists or failed to get registration options from server: ' + msg); + } + + // Convert the registration options to JSON. + const options = await response.json(); + + // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). + // A new attestation is created. This also means a new public-private-key pair is created. + const attestationResponse = await SimpleWebAuthnBrowser.startRegistration(options.publicKey); + + // Send attestationResponse back to server for verification and storage. + const verificationResponse = await fetch('/api/passkey/registerFinish', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(attestationResponse) + }); + + + const msg = await verificationResponse.json(); + if (verificationResponse.ok) { + showMessage(msg, false); + } else { + showMessage(msg, true); + } + } catch + (error) { + showMessage('Error: ' + error.message, true); + } +} + +async function login() { + // Retrieve the username from the input field + const username = document.getElementById('username').value; + + try { + // Get login options from your server. Here, we also receive the challenge. + const response = await fetch('/api/passkey/loginStart', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username: username}) + }); + // Check if the login options are ok. + if (!response.ok) { + const msg = await response.json(); + throw new Error('Failed to get login options from server: ' + msg); + } + // Convert the login options to JSON. + const options = await response.json(); + + // This triggers the browser to display the passkey / WebAuthn modal (e.g. Face ID, Touch ID, Windows Hello). + // A new assertionResponse is created. This also means that the challenge has been signed. + const assertionResponse = await SimpleWebAuthnBrowser.startAuthentication(options.publicKey); + + // Send assertionResponse back to server for verification. + const verificationResponse = await fetch('/api/passkey/loginFinish', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(assertionResponse) + }); + + const msg = await verificationResponse.json(); + if (verificationResponse.ok) { + showMessage(msg, false); + } else { + showMessage(msg, true); + } + } catch (error) { + showMessage('Error: ' + error.message, true); + } +} \ No newline at end of file