Repository: silinternational/serverless-mfa-api-go
Pull request #102: move webauthn functions into separate files
Author: login: briskt
Created: 2025-04-15T11:00:57Z | Merged: 2025-04-21T00:54:24Z

[IDP-1501](https://support.gtis.sil.org/issue/IDP-1501) refactor serverless-mfa-api-go

### Added
- Added a `User` interface for separation

### Changed
- Moved `IDParam`, `simpleError`, `newSimpleError`, and `jsonResponse` from webauthn.go to api.go
- Moved `AuthenticateRequest` to auth.go
- Moved the webauthn-specific portion of `AuthenticateRequest` to a separate function and only use it for webauthn endpoints
- Renamed `ApiMeta` to `WebauthnMeta`

### Fixed
- Added a missing return in `BeginRegistration`


Diff:
diff --git a/api.go b/api.go
new file mode 100644
index 0000000..e724740
--- /dev/null
+++ b/api.go
@@ -0,0 +1,50 @@
+package mfa
+
+import (
+	"encoding/json"
+	"log"
+	"net/http"
+)
+
+const IDParam = "id"
+
+// simpleError is a custom error type that can be JSON-encoded for API responses
+type simpleError struct {
+	Error string `json:"error"`
+}
+
+// newSimpleError creates a new simpleError from the given error
+func newSimpleError(err error) simpleError {
+	return simpleError{Error: err.Error()}
+}
+
+// jsonResponse encodes a body as JSON and writes it to the response. It sets the response Content-Type header to
+// "application/json".
+func jsonResponse(w http.ResponseWriter, body interface{}, status int) {
+	var data interface{}
+	switch b := body.(type) {
+	case error:
+		data = newSimpleError(b)
+	default:
+		data = body
+	}
+
+	var jBody []byte
+	var err error
+	if data != nil {
+		jBody, err = json.Marshal(data)
+		if err != nil {
+			log.Printf("failed to marshal response body to json: %s", err)
+			w.WriteHeader(http.StatusInternalServerError)
+			_, _ = w.Write([]byte("failed to marshal response body to json"))
+			return
+		}
+	}
+
+	w.Header().Set("content-type", "application/json")
+	w.WriteHeader(status)
+	_, err = w.Write(jBody)
+	if err != nil {
+		log.Printf("failed to write response in jsonResponse: %s\n", err)
+	}
+}
diff --git a/auth.go b/auth.go
new file mode 100644
index 0000000..954f455
--- /dev/null
+++ b/auth.go
@@ -0,0 +1,69 @@
+package mfa
+
+import (
+	"fmt"
+	"log"
+	"net/http"
+	"strings"
+)
+
+type User interface{}
+
+// AuthenticateRequest checks the provided API key against the keys stored in the database. If the key is active and
+// valid, a Webauthn client and WebauthnUser are created and stored in the request context.
+func AuthenticateRequest(r *http.Request) (User, error) {
+	// get key and secret from headers
+	key := r.Header.Get("x-mfa-apikey")
+	secret := r.Header.Get("x-mfa-apisecret")
+
+	if key == "" || secret == "" {
+		return nil, fmt.Errorf("x-mfa-apikey and x-mfa-apisecret are required")
+	}
+
+	log.Printf("API called by key: %s. %s %s", key, r.Method, r.RequestURI)
+
+	localStorage, err := NewStorage(envConfig.AWSConfig)
+	if err != nil {
+		return nil, fmt.Errorf("error initializing storage: %w", err)
+	}
+
+	apiKey := ApiKey{
+		Key:    key,
+		Secret: secret,
+		Store:  localStorage,
+	}
+
+	err = apiKey.Load()
+	if err != nil {
+		return nil, fmt.Errorf("failed to load api key: %w", err)
+	}
+
+	if apiKey.ActivatedAt == 0 {
+		return nil, fmt.Errorf("api call attempted for not yet activated key: %s", apiKey.Key)
+	}
+
+	valid, err := apiKey.IsCorrect(secret)
+	if err != nil {
+		return nil, fmt.Errorf("failed to validate api key: %w", err)
+	}
+
+	if !valid {
+		return nil, fmt.Errorf("invalid api secret for key %s", key)
+	}
+
+	path := r.URL.Path
+	segments := strings.Split(strings.TrimPrefix(path, "/"), "/")
+	switch segments[0] {
+	case "webauthn":
+		return authWebauthnUser(r, localStorage, apiKey)
+
+	case "totp":
+		return nil, fmt.Errorf("TOTP is not yet supported")
+
+	case "api-key":
+		return nil, fmt.Errorf("key management is not yet supported")
+
+	default:
+		return nil, fmt.Errorf("invalid URL: %s", r.URL)
+	}
+}
diff --git a/webauthn.go b/webauthn.go
index 35cd039..c079440 100644
--- a/webauthn.go
+++ b/webauthn.go
@@ -2,8 +2,6 @@ package mfa
 
 import (
 	"bytes"
-	"encoding/json"
-	"errors"
 	"fmt"
 	"io"
 	"log"
@@ -16,12 +14,10 @@ import (
 	uuid "github.com/satori/go.uuid"
 )
 
-const IDParam = "id"
-
-// ApiMeta holds metadata about the calling service for use in WebAuthn responses.
+// WebauthnMeta holds metadata about the calling service for use in WebAuthn responses.
 // Since this service/api is consumed by multiple sources this information cannot
 // be stored in the envConfig
-type ApiMeta struct {
+type WebauthnMeta struct {
 	RPDisplayName   string `json:"RPDisplayName"` // Display Name for your site
 	RPID            string `json:"RPID"`          // Generally the FQDN for your site
 	RPOrigin        string `json:"RPOrigin"`      // The origin URL for WebAuthn requests
@@ -52,9 +48,10 @@ type finishLoginResponse struct {
 // BeginRegistration processes the first half of the Webauthn Registration flow. It is the handler for the
 // "POST /webauthn/register" endpoint, initiated by the client when creation of a new passkey is requested.
 func BeginRegistration(w http.ResponseWriter, r *http.Request) {
-	user, err := getUserFromContext(r)
+	user, err := getWebauthnUser(r)
 	if err != nil {
 		jsonResponse(w, err, http.StatusBadRequest)
+		return
 	}
 
 	// If user.id is empty, treat as new user/registration
@@ -79,7 +76,7 @@ func BeginRegistration(w http.ResponseWriter, r *http.Request) {
 // FinishRegistration processes the last half of the Webauthn Registration flow. It is the handler for the
 // "PUT /webauthn/register" endpoint, initiated by the client with information encrypted by the new private key.
 func FinishRegistration(w http.ResponseWriter, r *http.Request) {
-	user, err := getUserFromContext(r)
+	user, err := getWebauthnUser(r)
 	if err != nil {
 		jsonResponse(w, err, http.StatusBadRequest)
 		return
@@ -101,7 +98,7 @@ func FinishRegistration(w http.ResponseWriter, r *http.Request) {
 // BeginLogin processes the first half of the Webauthn Authentication flow. It is the handler for the
 // "POST /webauthn/login" endpoint, initiated by the client at the beginning of a login request.
 func BeginLogin(w http.ResponseWriter, r *http.Request) {
-	user, err := getUserFromContext(r)
+	user, err := getWebauthnUser(r)
 	if err != nil {
 		jsonResponse(w, err, http.StatusBadRequest)
 		log.Printf("error getting user from context: %s\n", err)
@@ -121,7 +118,7 @@ func BeginLogin(w http.ResponseWriter, r *http.Request) {
 // FinishLogin processes the second half of the Webauthn Authentication flow. It is the handler for the
 // "PUT /webauthn/login" endpoint, initiated by the client with login data signed with the private key.
 func FinishLogin(w http.ResponseWriter, r *http.Request) {
-	user, err := getUserFromContext(r)
+	user, err := getWebauthnUser(r)
 	if err != nil {
 		jsonResponse(w, err, http.StatusBadRequest)
 		log.Printf("error getting user from context: %s\n", err)
@@ -146,7 +143,7 @@ func FinishLogin(w http.ResponseWriter, r *http.Request) {
 // DeleteUser is the handler for the "DELETE /webauthn/user" endpoint. It removes a user and any stored passkeys owned
 // by the user.
 func DeleteUser(w http.ResponseWriter, r *http.Request) {
-	user, err := getUserFromContext(r)
+	user, err := getWebauthnUser(r)
 	if err != nil {
 		jsonResponse(w, err, http.StatusBadRequest)
 		log.Printf("error getting user from context: %s\n", err)
@@ -166,7 +163,7 @@ func DeleteUser(w http.ResponseWriter, r *http.Request) {
 // passkey identified by "credID", which is the key_handle_hash returned by the FinishRegistration endpoint, or "u2f"
 // if it is a legacy U2F credential.
 func DeleteCredential(w http.ResponseWriter, r *http.Request) {
-	user, err := getUserFromContext(r)
+	user, err := getWebauthnUser(r)
 	if err != nil {
 		jsonResponse(w, err, http.StatusBadRequest)
 		log.Printf("error getting user from context: %s\n", err)
@@ -190,47 +187,6 @@ func DeleteCredential(w http.ResponseWriter, r *http.Request) {
 	jsonResponse(w, err, status)
 }
 
-// simpleError is a custom error type that can be JSON-encoded for API responses
-type simpleError struct {
-	Error string `json:"error"`
-}
-
-// newSimpleError creates a new simpleError from the given error
-func newSimpleError(err error) simpleError {
-	return simpleError{Error: err.Error()}
-}
-
-// jsonResponse encodes a body as JSON and writes it to the response. It sets the response Content-Type header to
-// "application/json".
-func jsonResponse(w http.ResponseWriter, body interface{}, status int) {
-	var data interface{}
-	switch b := body.(type) {
-	case error:
-		data = newSimpleError(b)
-	default:
-		data = body
-	}
-
-	var jBody []byte
-	var err error
-	if data != nil {
-		jBody, err = json.Marshal(data)
-		if err != nil {
-			log.Printf("failed to marshal response body to json: %s", err)
-			w.WriteHeader(http.StatusInternalServerError)
-			_, _ = w.Write([]byte("failed to marshal response body to json"))
-			return
-		}
-	}
-
-	w.Header().Set("content-type", "application/json")
-	w.WriteHeader(status)
-	_, err = w.Write(jBody)
-	if err != nil {
-		log.Printf("failed to write response in jsonResponse: %s\n", err)
-	}
-}
-
 // fixStringEncoding converts a string from standard Base64 to Base64-URL
 func fixStringEncoding(content string) string {
 	content = strings.ReplaceAll(content, "+", "-")
@@ -247,7 +203,7 @@ func fixEncoding(content []byte) io.Reader {
 
 // getWebAuthnFromApiMeta creates a new WebAuthn object from the metadata provided in a web request. Typically used in
 // the API authentication phase, early in the handler or in a middleware.
-func getWebAuthnFromApiMeta(meta ApiMeta) (*webauthn.WebAuthn, error) {
+func getWebAuthnFromApiMeta(meta WebauthnMeta) (*webauthn.WebAuthn, error) {
 	web, err := webauthn.New(&webauthn.Config{
 		RPDisplayName: meta.RPDisplayName,      // Display Name for your site
 		RPID:          meta.RPID,               // Generally the FQDN for your site
@@ -261,10 +217,10 @@ func getWebAuthnFromApiMeta(meta ApiMeta) (*webauthn.WebAuthn, error) {
 	return web, nil
 }
 
-// getApiMetaFromRequest creates an ApiMeta object from request headers, including basic validation checks. Used during
+// getWebauthnMetaFromRequest creates an WebauthnMeta object from request headers, including basic validation checks. Used during
 // API authentication.
-func getApiMetaFromRequest(r *http.Request) (ApiMeta, error) {
-	meta := ApiMeta{
+func getWebauthnMetaFromRequest(r *http.Request) (WebauthnMeta, error) {
+	meta := WebauthnMeta{
 		RPDisplayName:   r.Header.Get("x-mfa-RPDisplayName"),
 		RPID:            r.Header.Get("x-mfa-RPID"),
 		RPOrigin:        r.Header.Get("x-mfa-RPOrigin"),
@@ -278,79 +234,37 @@ func getApiMetaFromRequest(r *http.Request) (ApiMeta, error) {
 	// check that required fields are provided
 	if meta.RPDisplayName == "" {
 		msg := "missing required header: x-mfa-RPDisplayName"
-		return ApiMeta{}, fmt.Errorf(msg)
+		return WebauthnMeta{}, fmt.Errorf(msg)
 	}
 	if meta.RPID == "" {
 		msg := "missing required header: x-mfa-RPID"
-		return ApiMeta{}, fmt.Errorf(msg)
+		return WebauthnMeta{}, fmt.Errorf(msg)
 	}
 	if meta.Username == "" {
 		msg := "missing required header: x-mfa-Username"
-		return ApiMeta{}, fmt.Errorf(msg)
+		return WebauthnMeta{}, fmt.Errorf(msg)
 	}
 	if meta.UserDisplayName == "" {
 		msg := "missing required header: x-mfa-UserDisplayName"
-		return ApiMeta{}, fmt.Errorf(msg)
+		return WebauthnMeta{}, fmt.Errorf(msg)
 	}
 
 	return meta, nil
 }
 
-// getUserFromContext returns the authenticated WebauthnUser from the request context. The authentication middleware or
+// getWebauthnUser returns the authenticated WebauthnUser from the request context. The authentication middleware or
 // early handler processing inserts the authenticated user into the context for retrieval by this function.
-func getUserFromContext(r *http.Request) (*WebauthnUser, error) {
-	user, ok := r.Context().Value(UserContextKey).(*WebauthnUser)
+func getWebauthnUser(r *http.Request) (WebauthnUser, error) {
+	user, ok := r.Context().Value(UserContextKey).(WebauthnUser)
 	if !ok {
-		return &WebauthnUser{}, errors.New("unable to get user from request context")
+		return WebauthnUser{}, fmt.Errorf("unable to get user from request context")
 	}
 
 	return user, nil
 }
 
-// AuthenticateRequest checks the provided API key against the keys stored in the database. If the key is active and
-// valid, a Webauthn client and WebauthnUser are created and stored in the request context.
-func AuthenticateRequest(r *http.Request) (*WebauthnUser, error) {
-	// get key and secret from headers
-	key := r.Header.Get("x-mfa-apikey")
-	secret := r.Header.Get("x-mfa-apisecret")
-
-	if key == "" || secret == "" {
-		return nil, fmt.Errorf("x-mfa-apikey and x-mfa-apisecret are required")
-	}
-
-	log.Printf("API called by key: %s. %s %s", key, r.Method, r.RequestURI)
-
-	localStorage, err := NewStorage(envConfig.AWSConfig)
-	if err != nil {
-		return nil, fmt.Errorf("error initializing storage: %w", err)
-	}
-
-	apiKey := ApiKey{
-		Key:    key,
-		Secret: secret,
-		Store:  localStorage,
-	}
-
-	err = apiKey.Load()
-	if err != nil {
-		return nil, fmt.Errorf("failed to load api key: %w", err)
-	}
-
-	if apiKey.ActivatedAt == 0 {
-		return nil, fmt.Errorf("api call attempted for not yet activated key: %s", apiKey.Key)
-	}
-
-	valid, err := apiKey.IsCorrect(secret)
-	if err != nil {
-		return nil, fmt.Errorf("failed to validate api key: %w", err)
-	}
-
-	if !valid {
-		return nil, fmt.Errorf("invalid api secret for key %s", key)
-	}
-
-	// apiMeta includes info about the user and webauthn config
-	apiMeta, err := getApiMetaFromRequest(r)
+func authWebauthnUser(r *http.Request, storage *Storage, apiKey ApiKey) (User, error) {
+	apiMeta, err := getWebauthnMetaFromRequest(r)
 	if err != nil {
 		log.Printf("unable to retrieve API meta information from request: %s", err)
 		return nil, fmt.Errorf("unable to retrieve API meta information from request: %w", err)
@@ -361,7 +275,7 @@ func AuthenticateRequest(r *http.Request) (*WebauthnUser, error) {
 		return nil, fmt.Errorf("unable to create webauthn client from api meta config: %w", err)
 	}
 
-	user := NewWebauthnUser(apiMeta, localStorage, apiKey, webAuthnClient)
+	user := NewWebauthnUser(apiMeta, storage, apiKey, webAuthnClient)
 
 	// If this user exists (api key value is not empty), make sure the calling API Key owns the user and is allowed to operate on it
 	if user.ApiKeyValue != "" && user.ApiKeyValue != apiKey.Key {
@@ -369,5 +283,5 @@ func AuthenticateRequest(r *http.Request) (*WebauthnUser, error) {
 		return nil, fmt.Errorf("user does not exist")
 	}
 
-	return &user, nil
+	return user, nil
 }
diff --git a/webauthn_test.go b/webauthn_test.go
index a2700e9..dda48f9 100644
--- a/webauthn_test.go
+++ b/webauthn_test.go
@@ -57,7 +57,7 @@ func getTestAssertionRequest(credID1, authData1, clientData1, attestObject1 stri
 	body := io.NopCloser(bytes.NewReader(assertResp))
 
 	reqWithBody := &http.Request{Body: body}
-	ctxWithUser := context.WithValue(reqWithBody.Context(), UserContextKey, user)
+	ctxWithUser := context.WithValue(reqWithBody.Context(), UserContextKey, *user)
 	reqWithBody = reqWithBody.WithContext(ctxWithUser)
 	return reqWithBody
 }
@@ -147,7 +147,7 @@ func (ms *MfaSuite) Test_BeginRegistration() {
 	}
 
 	reqNoID := http.Request{}
-	ctxNoID := context.WithValue(reqNoID.Context(), UserContextKey, &userNoID)
+	ctxNoID := context.WithValue(reqNoID.Context(), UserContextKey, userNoID)
 	reqNoID = *reqNoID.WithContext(ctxNoID)
 
 	testUser := WebauthnUser{
@@ -161,7 +161,7 @@ func (ms *MfaSuite) Test_BeginRegistration() {
 	}
 
 	reqWithUserID := http.Request{}
-	ctxWithUserID := context.WithValue(reqWithUserID.Context(), UserContextKey, &testUser)
+	ctxWithUserID := context.WithValue(reqWithUserID.Context(), UserContextKey, testUser)
 	reqWithUserID = *reqWithUserID.WithContext(ctxWithUserID)
 
 	localStorage.Store(envConfig.WebauthnTable, ctxWithUserID)
@@ -179,7 +179,6 @@ func (ms *MfaSuite) Test_BeginRegistration() {
 			httpReq:    http.Request{},
 			wantBodyContains: []string{
 				`"error":"unable to get user from request context"`,
-				`missing WebAuthClient in BeginRegistration`,
 			},
 		},
 		{
@@ -291,7 +290,7 @@ func (ms *MfaSuite) Test_FinishRegistration() {
 	}
 
 	reqNoBody := http.Request{}
-	ctxNoBody := context.WithValue(reqNoBody.Context(), UserContextKey, &testUser)
+	ctxNoBody := context.WithValue(reqNoBody.Context(), UserContextKey, testUser)
 	reqNoBody = *reqNoBody.WithContext(ctxNoBody)
 
 	const credID = "dmlydEtleTExLTA"
@@ -473,11 +472,11 @@ func (ms *MfaSuite) Test_BeginLogin() {
 	}
 
 	reqNoCredentials := http.Request{}
-	ctxWithUser := context.WithValue(reqNoCredentials.Context(), UserContextKey, &userNoCreds)
+	ctxWithUser := context.WithValue(reqNoCredentials.Context(), UserContextKey, userNoCreds)
 	reqNoCredentials = *reqNoCredentials.WithContext(ctxWithUser)
 
 	reqWithCredentials := http.Request{}
-	ctxWithUserCredentials := context.WithValue(reqWithCredentials.Context(), UserContextKey, &userWithCreds)
+	ctxWithUserCredentials := context.WithValue(reqWithCredentials.Context(), UserContextKey, userWithCreds)
 	reqWithCredentials = *reqWithCredentials.WithContext(ctxWithUserCredentials)
 
 	localStorage.Store(envConfig.WebauthnTable, ctxWithUserCredentials)
@@ -640,7 +639,7 @@ func (ms *MfaSuite) Test_FinishLogin() {
 
 	body1 := io.NopCloser(bytes.NewReader([]byte(assertionResponse1)))
 	reqWithBody1 := http.Request{Body: body1}
-	ctxUserCred1 := context.WithValue(reqWithBody1.Context(), UserContextKey, &userWithCreds)
+	ctxUserCred1 := context.WithValue(reqWithBody1.Context(), UserContextKey, userWithCreds)
 	reqWithBody1 = *reqWithBody1.WithContext(ctxUserCred1)
 
 	localStorage.Store(envConfig.WebauthnTable, ctxUserCred1)
@@ -663,7 +662,7 @@ func (ms *MfaSuite) Test_FinishLogin() {
 	body2 := io.NopCloser(bytes.NewReader([]byte(assertionResponse2)))
 
 	reqWithBody2 := http.Request{Body: body2}
-	ctxUserCred2 := context.WithValue(reqWithBody2.Context(), UserContextKey, &userWithCreds)
+	ctxUserCred2 := context.WithValue(reqWithBody2.Context(), UserContextKey, userWithCreds)
 	reqWithBody2 = *reqWithBody2.WithContext(ctxUserCred2)
 
 	localStorage.Store(envConfig.WebauthnTable, ctxUserCred2)
@@ -875,7 +874,7 @@ func (ms *MfaSuite) Test_DeleteCredential() {
 			request.Header.Set("x-mfa-Username", tt.user.Name)
 			request.Header.Set("x-mfa-UserDisplayName", tt.user.DisplayName)
 
-			ctxWithUser := context.WithValue(request.Context(), UserContextKey, &tt.user)
+			ctxWithUser := context.WithValue(request.Context(), UserContextKey, tt.user)
 			request = request.WithContext(ctxWithUser)
 			baseConfigs.Storage.Store(baseConfigs.EnvConfig.WebauthnTable, ctxWithUser)
 
diff --git a/user.go b/webauthnuser.go
similarity index 99%
rename from user.go
rename to webauthnuser.go
index d2e8add..6845adb 100644
--- a/user.go
+++ b/webauthnuser.go
@@ -60,7 +60,7 @@ type WebauthnUser struct {
 }
 
 // NewWebauthnUser creates a new WebauthnUser from API input data, a storage client and a Webauthn client.
-func NewWebauthnUser(apiConfig ApiMeta, storage *Storage, apiKey ApiKey, webAuthnClient *webauthn.WebAuthn) WebauthnUser {
+func NewWebauthnUser(apiConfig WebauthnMeta, storage *Storage, apiKey ApiKey, webAuthnClient *webauthn.WebAuthn) WebauthnUser {
 	u := WebauthnUser{
 		ID:             apiConfig.UserUUID,
 		Name:           apiConfig.Username,
diff --git a/user_test.go b/webauthnuser_test.go
similarity index 100%
rename from user_test.go
rename to webauthnuser_test.go


Reviews:
user: login: hobbitronics
body: Looks good. General question though since I see the other methods aren't implemented. Are we using this in idp or elsewhere yet?
state: APPROVED
author_association: NONE
submitted_at: 2025-04-21T00:48:37Z
user: login: briskt
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T00:52:47Z
user: login: hobbitronics
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-21T01:19:41Z

Review comments:
diff_hunk: @@ -0,0 +1,69 @@
+package mfa
+
+import (
+	"fmt"
+	"log"
+	"net/http"
+	"strings"
+)
+
+type User interface{}
+
+// AuthenticateRequest checks the provided API key against the keys stored in the database. If the key is active and
+// valid, a Webauthn client and WebauthnUser are created and stored in the request context.
+func AuthenticateRequest(r *http.Request) (User, error) {
+	// get key and secret from headers
+	key := r.Header.Get("x-mfa-apikey")
+	secret := r.Header.Get("x-mfa-apisecret")
+
+	if key == "" || secret == "" {
+		return nil, fmt.Errorf("x-mfa-apikey and x-mfa-apisecret are required")
+	}
+
+	log.Printf("API called by key: %s. %s %s", key, r.Method, r.RequestURI)
+
+	localStorage, err := NewStorage(envConfig.AWSConfig)
+	if err != nil {
+		return nil, fmt.Errorf("error initializing storage: %w", err)
+	}
+
+	apiKey := ApiKey{
+		Key:    key,
+		Secret: secret,
+		Store:  localStorage,
+	}
+
+	err = apiKey.Load()
+	if err != nil {
+		return nil, fmt.Errorf("failed to load api key: %w", err)
+	}
+
+	if apiKey.ActivatedAt == 0 {
+		return nil, fmt.Errorf("api call attempted for not yet activated key: %s", apiKey.Key)
+	}
+
+	valid, err := apiKey.IsCorrect(secret)
+	if err != nil {
+		return nil, fmt.Errorf("failed to validate api key: %w", err)
+	}
+
+	if !valid {
+		return nil, fmt.Errorf("invalid api secret for key %s", key)
+	}
+
+	path := r.URL.Path
+	segments := strings.Split(strings.TrimPrefix(path, "/"), "/")
+	switch segments[0] {
+	case "webauthn":
+		return authWebauthnUser(r, localStorage, apiKey)
+
+	case "totp":
+		return nil, fmt.Errorf("TOTP is not yet supported")
+
+	case "api-key":
+		return nil, fmt.Errorf("key management is not yet supported")
path: auth.go
user: login: briskt
body: @hobbitronics I assume you were asking about the 'totp' and 'api-key' URL paths. Those are implemented in https://github.com/silinternational/serverless-mfa-api but my intent is to consolidate into this repo since they both share the same apiKey table and each IdP uses the same key/secret for both sets of API endpoints. This was the original intent, I think, but we just never got around to it.
created_at: 2025-04-21T00:52:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,69 @@
+package mfa
+
+import (
+	"fmt"
+	"log"
+	"net/http"
+	"strings"
+)
+
+type User interface{}
+
+// AuthenticateRequest checks the provided API key against the keys stored in the database. If the key is active and
+// valid, a Webauthn client and WebauthnUser are created and stored in the request context.
+func AuthenticateRequest(r *http.Request) (User, error) {
+	// get key and secret from headers
+	key := r.Header.Get("x-mfa-apikey")
+	secret := r.Header.Get("x-mfa-apisecret")
+
+	if key == "" || secret == "" {
+		return nil, fmt.Errorf("x-mfa-apikey and x-mfa-apisecret are required")
+	}
+
+	log.Printf("API called by key: %s. %s %s", key, r.Method, r.RequestURI)
+
+	localStorage, err := NewStorage(envConfig.AWSConfig)
+	if err != nil {
+		return nil, fmt.Errorf("error initializing storage: %w", err)
+	}
+
+	apiKey := ApiKey{
+		Key:    key,
+		Secret: secret,
+		Store:  localStorage,
+	}
+
+	err = apiKey.Load()
+	if err != nil {
+		return nil, fmt.Errorf("failed to load api key: %w", err)
+	}
+
+	if apiKey.ActivatedAt == 0 {
+		return nil, fmt.Errorf("api call attempted for not yet activated key: %s", apiKey.Key)
+	}
+
+	valid, err := apiKey.IsCorrect(secret)
+	if err != nil {
+		return nil, fmt.Errorf("failed to validate api key: %w", err)
+	}
+
+	if !valid {
+		return nil, fmt.Errorf("invalid api secret for key %s", key)
+	}
+
+	path := r.URL.Path
+	segments := strings.Split(strings.TrimPrefix(path, "/"), "/")
+	switch segments[0] {
+	case "webauthn":
+		return authWebauthnUser(r, localStorage, apiKey)
+
+	case "totp":
+		return nil, fmt.Errorf("TOTP is not yet supported")
+
+	case "api-key":
+		return nil, fmt.Errorf("key management is not yet supported")
path: auth.go
user: login: hobbitronics
body: Thanks, that's kind of what I was assuming.
created_at: 2025-04-21T01:19:41Z
author_association: NONE

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-16T10:12:41Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=silinternational_serverless-mfa-api-go&pullRequest=102) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [2 New issues](https://sonarcloud.io/project/issues?id=silinternational_serverless-mfa-api-go&pullRequest=102&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=silinternational_serverless-mfa-api-go&pullRequest=102&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=silinternational_serverless-mfa-api-go&pullRequest=102&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=silinternational_serverless-mfa-api-go&pullRequest=102&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [5.1% Duplication on New Code](https://sonarcloud.io/component_measures?id=silinternational_serverless-mfa-api-go&pullRequest=102&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=silinternational_serverless-mfa-api-go&pullRequest=102)


author_association: NONE