Skip to content
Open
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
20 changes: 19 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,22 @@ tmp/
limen-publish
go.work
go.work.sum
examples/kitchen/
examples/kitchen/

# Generated per-developer migration SQL produced by `limen generate migrations`.
# Each developer regenerates these against their own database; they should not be checked in.
examples/*/migrations/

# Locally-built CLI binaries from `cd cmd/limen && go build`.
cmd/limen/limen
cmd/limen/limen.exe

# Local test artifacts (curl cookie jars, etc.).
cookies.txt

# Claude Code local configuration.
.claude/

# Go build artifacts left in example dirs (e.g. `go build` without `-o`).
examples/*/*.exe
examples/*/*/*.exe
2 changes: 2 additions & 0 deletions constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const (
PluginOAuth PluginName = "oauth"
PluginSessionJWT PluginName = "session-jwt"
PluginMagicLink PluginName = "magic-link"
PluginEmailOTP PluginName = "email-otp"
PluginPasskey PluginName = "passkey"
)

// ============================================================================
Expand Down
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ DATABASE_URL="postgres://..." go run ./examples/basic
DATABASE_URL="postgres://..." go run ./examples/gin
DATABASE_URL="postgres://..." GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... go run ./examples/oauth-google
DATABASE_URL="postgres://..." go run ./examples/two-factor
DATABASE_URL="postgres://..." go run ./examples/passkey
DATABASE_URL="postgres://..." go run ./examples/adapters/gorm
DATABASE_URL="postgres://..." go run ./examples/adapters/sql
```
Expand All @@ -29,6 +30,7 @@ DATABASE_URL="postgres://..." go run ./examples/adapters/sql
| `gin` | GORM | credential-password | -- |
| `oauth-google` | GORM | OAuth (Google) | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` |
| `two-factor` | GORM | credential-password, two-factor | -- |
| `passkey` | `database/sql` | credential-password, passkey | -- |
| `adapters/gorm` | GORM | credential-password | -- |
| `adapters/sql` | `database/sql` | credential-password | -- |

Expand Down
32 changes: 32 additions & 0 deletions examples/passkey/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
module example/passkey

go 1.25.0

require (
github.com/lib/pq v1.10.9
github.com/thecodearcher/limen v0.1.1
github.com/thecodearcher/limen/adapters/sql v0.0.1
github.com/thecodearcher/limen/plugins/credential-password v0.0.1
github.com/thecodearcher/limen/plugins/passkey v0.0.0
)

require (
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-webauthn/webauthn v0.13.4 // indirect
github.com/go-webauthn/x v0.1.23 // indirect
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.41.0 // indirect
)

replace (
github.com/thecodearcher/limen => ../..
github.com/thecodearcher/limen/adapters/sql => ../../adapters/sql
github.com/thecodearcher/limen/plugins/credential-password => ../../plugins/credential-password
github.com/thecodearcher/limen/plugins/passkey => ../../plugins/passkey
)
56 changes: 56 additions & 0 deletions examples/passkey/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-webauthn/webauthn v0.13.4 h1:q68qusWPcqHbg9STSxBLBHnsKaLxNO0RnVKaAqMuAuQ=
github.com/go-webauthn/webauthn v0.13.4/go.mod h1:MglN6OH9ECxvhDqoq1wMoF6P6JRYDiQpC9nc5OomQmI=
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
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/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
186 changes: 186 additions & 0 deletions examples/passkey/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Limen Passkey Demo</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
h1 { margin-bottom: 0.25rem; }
h2 { margin-top: 2rem; border-bottom: 1px solid #ddd; padding-bottom: 0.25rem; }
input { display: block; margin: 0.25rem 0 0.5rem; padding: 0.4rem; width: 100%; max-width: 320px; }
button { margin: 0.25rem 0; padding: 0.4rem 0.8rem; cursor: pointer; }
pre { background: #f4f4f4; padding: 0.5rem; overflow: auto; border-radius: 4px; font-size: 12px; }
.row { display: flex; gap: 0.5rem; align-items: center; }
.status { padding: 0.4rem 0.6rem; border-radius: 4px; margin-top: 0.5rem; }
.ok { background: #d4edda; }
.err { background: #f8d7da; }
</style>
</head>
<body>
<h1>Limen Passkey Demo</h1>
<p>
Bare-bones HTML to exercise the passkey plugin end-to-end. Use Chrome
DevTools → <em>WebAuthn</em> panel to enable a virtual authenticator so
no physical key is required.
</p>

<h2>1. Create an account or sign in</h2>
<p>Passkey registration requires an authenticated session. Use email + password to get one.</p>
<input id="email" placeholder="email" value="alice@example.com" />
<input id="password" type="password" placeholder="password" value="hunter2!HUNTER2" />
<div class="row">
<button onclick="signUp()">Sign up</button>
<button onclick="signIn()">Sign in</button>
</div>
<div id="auth-status"></div>

<h2>2. Register a passkey</h2>
<input id="passkey-name" placeholder="passkey name" value="My device" />
<button onclick="registerPasskey()">Register passkey</button>
<div id="register-status"></div>

<h2>3. Sign in with a passkey</h2>
<button onclick="signOut()">Sign out first</button>
<button onclick="authenticateWithPasskey()">Sign in with passkey</button>
<div id="auth-passkey-status"></div>

<h2>4. Manage passkeys</h2>
<button onclick="listPasskeys()">List my passkeys</button>
<pre id="passkeys-list"></pre>

<script>
const $ = (id) => document.getElementById(id);
const setStatus = (id, ok, msg) => {
const el = $(id);
el.className = "status " + (ok ? "ok" : "err");
el.textContent = msg;
};

const fetchJSON = async (url, opts = {}) => {
const res = await fetch(url, {
credentials: "include",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
const text = await res.text();
let body;
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
if (!res.ok) throw new Error(typeof body === "string" ? body : (body?.message || res.statusText));
return body;
};

// Base64url helpers for shuttling bytes between JSON and BufferSource.
const b64uToBuf = (s) => {
const pad = "=".repeat((4 - (s.length % 4)) % 4);
const b64 = (s + pad).replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out.buffer;
};
const bufToB64u = (buf) => {
const bin = String.fromCharCode(...new Uint8Array(buf));
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
};

async function signUp() {
try {
await fetchJSON("/api/auth/signup/credential", {
method: "POST",
body: JSON.stringify({ email: $("email").value, password: $("password").value }),
});
setStatus("auth-status", true, "Signed up + signed in.");
} catch (e) { setStatus("auth-status", false, "sign-up failed: " + e.message); }
}

async function signIn() {
try {
await fetchJSON("/api/auth/signin/credential", {
method: "POST",
body: JSON.stringify({ credential: $("email").value, password: $("password").value }),
});
setStatus("auth-status", true, "Signed in.");
} catch (e) { setStatus("auth-status", false, "sign-in failed: " + e.message); }
}

async function signOut() {
try {
await fetchJSON("/api/auth/signout", { method: "POST" });
setStatus("auth-status", true, "Signed out.");
} catch (e) { setStatus("auth-status", false, "sign-out failed: " + e.message); }
}

async function registerPasskey() {
try {
const name = encodeURIComponent($("passkey-name").value || "");
const wrapped = await fetchJSON("/api/auth/passkey/generate-register-options?name=" + name);
// The Go protocol library wraps options under `publicKey`.
const options = wrapped.publicKey || wrapped;

// Convert base64url fields to ArrayBuffers for the browser API.
options.challenge = b64uToBuf(options.challenge);
options.user.id = b64uToBuf(options.user.id);
if (options.excludeCredentials) {
options.excludeCredentials = options.excludeCredentials.map((c) => ({
...c, id: b64uToBuf(c.id),
}));
}

const cred = await navigator.credentials.create({ publicKey: options });
const body = {
id: cred.id,
rawId: bufToB64u(cred.rawId),
type: cred.type,
response: {
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
attestationObject: bufToB64u(cred.response.attestationObject),
transports: cred.response.getTransports?.() || [],
},
clientExtensionResults: cred.getClientExtensionResults?.() || {},
};
const result = await fetchJSON("/api/auth/passkey/verify-registration", {
method: "POST", body: JSON.stringify(body),
});
setStatus("register-status", true, "Registered: " + JSON.stringify(result));
} catch (e) { setStatus("register-status", false, "register failed: " + e.message); }
}

async function authenticateWithPasskey() {
try {
const wrapped = await fetchJSON("/api/auth/passkey/generate-authenticate-options");
const options = wrapped.publicKey || wrapped;
options.challenge = b64uToBuf(options.challenge);
if (options.allowCredentials) {
options.allowCredentials = options.allowCredentials.map((c) => ({
...c, id: b64uToBuf(c.id),
}));
}
const assertion = await navigator.credentials.get({ publicKey: options });
const body = {
id: assertion.id,
rawId: bufToB64u(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: bufToB64u(assertion.response.clientDataJSON),
authenticatorData: bufToB64u(assertion.response.authenticatorData),
signature: bufToB64u(assertion.response.signature),
userHandle: assertion.response.userHandle ? bufToB64u(assertion.response.userHandle) : null,
},
clientExtensionResults: assertion.getClientExtensionResults?.() || {},
};
const result = await fetchJSON("/api/auth/passkey/verify-authentication", {
method: "POST", body: JSON.stringify(body),
});
setStatus("auth-passkey-status", true, "Signed in via passkey: " + JSON.stringify(result));
} catch (e) { setStatus("auth-passkey-status", false, "passkey auth failed: " + e.message); }
}

async function listPasskeys() {
try {
const list = await fetchJSON("/api/auth/passkey/list");
$("passkeys-list").textContent = JSON.stringify(list, null, 2);
} catch (e) { $("passkeys-list").textContent = "list failed: " + e.message; }
}
</script>
</body>
</html>
63 changes: 63 additions & 0 deletions examples/passkey/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package main

import (
"database/sql"
"embed"
"log"
"net/http"
"os"

_ "github.com/lib/pq"

"github.com/thecodearcher/limen"
sqladapter "github.com/thecodearcher/limen/adapters/sql"
credentialpassword "github.com/thecodearcher/limen/plugins/credential-password"
"github.com/thecodearcher/limen/plugins/passkey"
)

//go:embed index.html
var staticFS embed.FS

func main() {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
log.Fatal("set DATABASE_URL")
}

db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()

auth, err := limen.New(&limen.Config{
BaseURL: "http://localhost:8080",
Database: sqladapter.NewPostgreSQL(db),
Secret: []byte("0123456789abcdef0123456789abcdef"),
HTTP: limen.NewDefaultHTTPConfig(limen.WithHTTPBasePath("/api/auth")),
CLI: &limen.CLIConfig{Enabled: true},
Plugins: []limen.Plugin{
// We pair passkey with credential-password so testers can
// create an account first, then register a passkey against
// that account.
credentialpassword.New(),
passkey.New(
passkey.WithRPID("localhost"),
passkey.WithOrigins("http://localhost:8080"),
passkey.WithRPName("Limen Passkey Demo"),
),
},
})
if err != nil {
log.Fatal(err)
}

mux := http.NewServeMux()
mux.Handle("/api/auth/", auth.Handler())
mux.Handle("/", http.FileServer(http.FS(staticFS)))

log.Println("passkey example listening on http://localhost:8080")
log.Println(" open http://localhost:8080 in Chrome and use the virtual authenticator")
log.Println(" (DevTools → ... → More tools → WebAuthn → Enable virtual authenticator)")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Loading