Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
61cf03b
Merge branch 'main' into develop
briskt May 30, 2025
150d272
Merge pull request #127 from silinternational/main
briskt Jun 11, 2025
e5fcf94
Update CODEOWNERS
ethancanne Sep 10, 2025
b15a75a
Merge pull request #128 from sil-org/rename-org
ethancanne Sep 11, 2025
46d5a35
add the POST /totp endpoint which creates a new TOTP
briskt Sep 12, 2025
d517caf
make api-key tests a bit more precise
briskt Sep 12, 2025
3dea567
refactor router to reduce duplication and remove an external dependency
briskt Sep 15, 2025
c3471c6
limit test server to DELETE
briskt Sep 15, 2025
4230fbf
add /router to Docker image
briskt Sep 15, 2025
8f52fd7
clean up .dockerignore
briskt Sep 15, 2025
995c106
add documentation for POST /totp
briskt Sep 16, 2025
940426d
Merge branch 'new-totp-endpoint' into route-refactor
briskt Sep 16, 2025
cd6f2a7
remove unused containers from the Docker Compose configuration
briskt Sep 16, 2025
62dfc1d
remove incorrect note in the open API spec
briskt Sep 16, 2025
3a4cba5
create TOTP table in "make dbinit"
briskt Sep 16, 2025
1736edd
CDK doesn't need to be in the Dockerfile
briskt Sep 16, 2025
f825c6f
don't return the error details in authentication response
briskt Sep 16, 2025
f732781
add missing route, BeginRegistration, which was defined for Lambda
briskt Sep 16, 2025
c592228
use CDK and SAM to serve the Lambda locally
briskt Sep 16, 2025
4de9c3b
add some documentation details for clarity (PR feedback)
briskt Sep 17, 2025
677b643
PR feedback: simplify region assignment
briskt Sep 17, 2025
e4b81ae
Merge pull request #129 from sil-org/new-totp-endpoint
briskt Sep 17, 2025
2f60d6c
Merge branch 'develop' into route-refactor
briskt Sep 17, 2025
bb13f4a
Merge branch 'route-refactor' into misc
briskt Sep 17, 2025
1835c16
Merge branch 'misc' into cdk-sam-testing
briskt Sep 17, 2025
e71f1d9
address a SonarQube security alert about log injection
briskt Sep 17, 2025
97a89a1
new test helper newPasscode()
briskt Sep 17, 2025
5199dae
implement DELETE /totp/{uuid} endpoint
briskt Sep 17, 2025
c183360
remove satori/go-uuid
briskt Sep 17, 2025
7ebb231
new test helper newRequest
briskt Sep 17, 2025
2f2e093
implement POST /totp/{uuid}/validate endpoint
briskt Sep 17, 2025
38e8e10
simplify route list to a map (rather than a slice of struct)
briskt Sep 17, 2025
ea28e49
move Delete Credential docs to OpenAPI spec
briskt Sep 17, 2025
6ae2f21
Merge pull request #130 from sil-org/route-refactor
briskt Sep 17, 2025
70373e2
simplify mux in u2fserver
briskt Sep 17, 2025
dc59029
Merge pull request #131 from sil-org/misc
briskt Sep 17, 2025
0613302
Merge pull request #132 from sil-org/cdk-sam-testing
briskt Sep 18, 2025
4f10b97
remove trailing slash from Delete Credential route
briskt Sep 18, 2025
2a89fb0
don't send error detail to client
briskt Sep 18, 2025
cd41169
move mux init outside test loop
briskt Sep 18, 2025
91666c2
another place where SonarQube flagged as vulnerable to injection attack
briskt Sep 18, 2025
be1f8ac
Merge pull request #133 from sil-org/feature/totp-delete
briskt Sep 18, 2025
d69c196
Merge branch 'develop' into feature/remove-satori
briskt Sep 18, 2025
3051f62
Merge pull request #134 from sil-org/feature/remove-satori
briskt Sep 18, 2025
87dbb03
Merge branch 'develop' into feature/totp-validate
briskt Sep 18, 2025
12aaa92
move response error consts to global scope
briskt Sep 18, 2025
fbe11c8
do not respond to client with a detailed error message (again)
briskt Sep 18, 2025
f866572
don't use a double pointer, even though json.Decode handles it OK
briskt Sep 18, 2025
5c43f82
move mux init outside test loop
briskt Sep 18, 2025
88db0e9
Merge pull request #135 from sil-org/feature/totp-validate
briskt Sep 18, 2025
83e7e50
Merge branch 'develop' into feature/simplify-routes
briskt Sep 18, 2025
05a0ed5
Merge pull request #136 from sil-org/feature/simplify-routes
briskt Sep 18, 2025
896f37c
fix TOTP Validate - decrypt and use the actual secret
briskt Sep 18, 2025
1eda72e
log errors but don't send the details to the client
briskt Sep 18, 2025
2ef424d
refine the simpleError type to be a bit more useful
briskt Sep 18, 2025
ee2bf57
didn't intend to commit this
briskt Sep 18, 2025
80a7339
remove unused code
briskt Sep 18, 2025
f946511
Merge pull request #137 from sil-org/fix-totp-validate
briskt Sep 18, 2025
f55dfd7
PR feedback (typo, nested errors.Is, default in switch, use const)
briskt Sep 19, 2025
dc28fad
rename local variable by Sonar recommendation
briskt Sep 19, 2025
f1c99db
Merge pull request #138 from sil-org/safe-response
briskt Sep 19, 2025
36639c5
Merge pull request #139 from sil-org/remove-unused
briskt Sep 19, 2025
a769b05
remove unnecessary indirection
briskt Sep 19, 2025
3f40446
rename docker-compose.yml to compose.yaml
briskt Sep 24, 2025
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
11 changes: 11 additions & 0 deletions .air-cdk.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
root = "."
tmp_dir = "tmp"

[build]
bin = ""
cmd = './build.sh'
delay = 100
exclude_dir = ["tmp", "cdk"]
full_bin = "sam local start-api --port 8160 --template cdk/cdk.out/twosv-api-dev.template.json --env-vars cdk/env.json"
include_ext = ["go"]
kill_delay = "0s"
3 changes: 1 addition & 2 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
*

# Whitelist required files
!.env.encrypted
!scripts/*
!lambda/*
!router/*
!server/*
!u2fsimulator/*
!u2fserver/*
Expand Down
8 changes: 4 additions & 4 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
* @silinternational/developers
*.tf @silinternational/tf-devs
*.go @silinternational/go-devs
go.* @silinternational/go-devs
* @sil-org/developers
*.tf @sil-org/tf-devs
*.go @sil-org/go-devs
go.* @sil-org/go-devs
28 changes: 6 additions & 22 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,32 +1,16 @@
FROM node:22
FROM golang:1.24

ENV GO_VERSION=1.24.4

ADD https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip .
ADD https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz .

RUN <<EOF
unzip awscli-exe-linux-x86_64.zip
rm awscli-exe-linux-x86_64.zip
./aws/install
rm -rf ./aws

tar -C /usr/local -xzf go${GO_VERSION}.linux-amd64.tar.gz
rm go${GO_VERSION}.linux-amd64.tar.gz
ln -s /usr/local/go/bin/go /usr/local/bin/go

npm install --ignore-scripts --global aws-cdk

adduser user
EOF
RUN adduser user

WORKDIR /src

RUN curl -sSfL --proto "=https" https://raw.githubusercontent.com/cosmtrek/air/master/install.sh | \
sh -s -- -b $(go env GOPATH)/bin
sh -s -- -b /usr/local/bin && \
git config --global --add safe.directory /src

COPY ./ .
RUN go get ./...
RUN go get ./... && \
git config --global --add safe.directory /src

EXPOSE 8080

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ test: clean db
db:
docker compose up -d dynamo

dbinit: db wait createwebauthntable createapikeytable
dbinit: db wait createwebauthntable createapikeytable createtotptable

wait:
sleep 5
Expand Down
109 changes: 102 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,56 @@
# A Serverless MFA API with support for WebAuthn
# A Serverless MFA API with support for TOTP and WebAuthn

This project provides a semi-generic backend API for supporting WebAuthn credential registration and authentication.
It is intended to be run in a manner as to be shared between multiple consuming applications. It uses an API key
and secret to authenticate requests, and further uses that secret as the encryption key. Loss of the API secret
would mean loss of all WebAuthn credentials stored.
This project provides a semi-generic backend API for supporting Time-based One Time Passcode (TOTP) and WebAuthn
Passkey registration and authentication. It is intended to be run in a manner as to be shared between multiple consuming
applications. It uses an API key and secret to authenticate requests, and further uses that secret as the encryption
key. Loss of the API secret would mean loss of all credentials stored.

This application can be run in two ways:
1. As a standalone server using the builtin webserver available in the `server/` folder
2. As a AWS Lambda function using the `lambda/` implementation. This implementation can also use
2. As an AWS Lambda function using the `lambda/` implementation. This implementation can also use
[AWS CDK](https://aws.amazon.com/cdk/) to help automate build/deployment. It should also be
noted that the `lambda` format depends on some resources already existing in AWS. There is a `lambda/terraform/`
folder with the Terraform configurations needed to provision them.

## The API
# API definition

The full definition of the API is found in the openapi.yaml file. A brief summary follows.

## The APIKey API

### Create APIKey

`POST /api-key`

### Activate APIKey

`POST /api-key/activate`

### Rotate APIKey (experimental)

This endpoint has not yet been proven in production use. Proceed at your own risk.

`POST /api-key/rotate`

## The TOTP API

### Required Headers
1. `x-mfa-apikey` - The API Key
2. `x-mfa-apisecret` - The API Key Secret

### Create TOTP Passcode

`POST /totp`

### Delete TOTP Passcode

`DELETE /totp/{uuid}`

### Validate TOTP Passcode

`POST /totp/{uuid}/validate`

## The Webauthn API
Yes, as you'll see below this API makes heavy use of custom headers for things that seem like they could go into
the request body. We chose to use headers though so that what is sent in the body can be handed off directly
to the WebAuthn library and fit the structures it was expecting without causing any conflicts, etc.
Expand Down Expand Up @@ -52,3 +90,60 @@ to do with WebAuthn, but is the primary key for finding the right records in Dyn

### Delete one of the user's Webauthn credentials
`DELETE /webauthn/credential`

# Development

## Unit tests

To run unit tests, simply run "make test". It will spin up a Docker Compose environment and run the tests using
Docker containers for the API and for DynamoDB.

## Manual testing

Unit tests can be run individually, either on the command line or through your IDE. It is also possible to
test the server and Lambda implementations locally.

### Server

#### HTTP

If HTTPS is not needed, simply start the `app` container and exercise the API using localhost and the Docker port
defined in docker-compose.yml (currently 8161).

#### HTTPS

To use a "demo UI" that can interact with the API using HTTPS, use Traefik proxy, which is defined in the Docker
Compose environment. Traefik is a proxy that creates a Let's Encrypt certificate and routes traffic to the local
container via a registered DNS record. To configure this, define the following variables in `local.env`:

- DNS_PROVIDER=cloudflare
- CLOUDFLARE_DNS_API_TOKEN=<insert a valid Cloudflare token that has DNS write permission on the domain defined below>
- LETS_ENCRYPT_EMAIL=<insert your actual email address here>
- LETS_ENCRYPT_CA=production
- TLD=<your DNS domain>
- SANS=mfa-ui.<your domain>,mfa-app.<your domain>
- BACKEND1_URL=http://ui:80
- FRONTEND1_DOMAIN=mfa-ui.<your domain>
- BACKEND2_URL=http://app:8080
- FRONTEND2_DOMAIN=mfa-app.<your domain>

Create DNS A records (without Cloudflare proxy enabled) for the values defined in `FRONTEND1_DOMAIN` and
`FRONTEND2_DOMAIN` pointing to 127.0.0.1 and wait for DNS propagation. Once all of the above configuration is in place,
run `make demo`. The first time will take several minutes for all the initialization. You can watch Docker logs on the
proxy container to keep tabs on the progress.

### Lambda

To exercise the API as it would be used in AWS Lambda, run this command: `air -c .air-cdk.toml`. This will run a
file watcher that will rebuild the app code and the CDK stack, then run `sam local start-api` using the generated
Cloudformation template. This will listen on port 8160. Any code changes will trigger a rebuild and SAM will restart
using the new code.

Implementation notes:

- SAM uses Docker internally, which would make it complicated to run with Docker Compose.
- You will need to install CDK and SAM on your computer for this to work.
- It can use the DynamoDB container in Docker Compose, which can be started using `make dbinit`.
- The `make dbinit` command creates an APIKey (key: `EC7C2E16-5028-432F-8AF2-A79A64CF3BC1`
secret: `1ED18444-7238-410B-A536-D6C15A3C`)
- Some unit tests will delete the APIKey created by `make dbinit`.
41 changes: 34 additions & 7 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,32 @@ package mfa

import (
"encoding/json"
"errors"
"log"
"net/http"
"strings"

"github.com/google/uuid"
)

const IDParam = "id"
const (
IDParam = "id"
UUIDParam = "uuid"
)

// simpleError is a custom error type that can be JSON-encoded for API responses
type simpleError struct {
Error string `json:"error"`
Err string `json:"error"`
}

// newSimpleError creates a new simpleError from the given error
func newSimpleError(err error) simpleError {
return simpleError{Error: err.Error()}
}
func newSimpleError(err error) simpleError { return simpleError{Err: err.Error()} }

// Error satisfies the error interface.
func (s simpleError) Error() string { return s.Err }

// Is returns true if the error strings are equal.
func (s simpleError) Is(err error) bool { return s.Err == err.Error() || errors.Is(err, simpleError{}) }

// jsonResponse encodes a body as JSON and writes it to the response. It sets the response Content-Type header to
// "application/json".
Expand All @@ -25,6 +36,8 @@ func jsonResponse(w http.ResponseWriter, body interface{}, status int) {
switch b := body.(type) {
case error:
data = newSimpleError(b)
case string:
data = newSimpleError(errors.New(b))
default:
data = body
}
Expand All @@ -34,7 +47,12 @@ func jsonResponse(w http.ResponseWriter, body interface{}, status int) {
if data != nil {
jBody, err = json.Marshal(data)
if err != nil {
log.Printf("failed to marshal response body to json: %s", err)

// SonarQube flagged this as vulnerable to injection attacks. Rather than exhaustively search for places
// where user input is inserted into the error message, I'll just sanitize it as recommended.
sanitizedError := strings.ReplaceAll(strings.ReplaceAll(err.Error(), "\n", "_"), "\r", "_")

log.Printf("failed to marshal response body to json: %s", sanitizedError)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("failed to marshal response body to json"))
return
Expand All @@ -45,6 +63,15 @@ func jsonResponse(w http.ResponseWriter, body interface{}, status int) {
w.WriteHeader(status)
_, err = w.Write(jBody)
if err != nil {
log.Printf("failed to write response in jsonResponse: %s\n", err)
log.Printf("failed to write response in jsonResponse: %s", err)
}
}

// NewUUID returns a new V4 UUID value as a text string
func NewUUID() string {
u, err := uuid.NewRandom()
if err != nil {
panic("failed to generate uuid: " + err.Error())
}
return u.String()
}
Loading