11 Commits

Author SHA1 Message Date
9385c3846d Fix protobuf runtime panic on startup
Regenerated protobuf stubs and bumped google.golang.org/protobuf from
v1.36.10 to v1.36.11 to match protoc-gen-go v1.36.11. The version
mismatch caused a panic in filedesc.unmarshalSeed during init().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:55:15 -07:00
e450ade988 Add SSO authorization code flow (Phase 1)
MCIAS now acts as an SSO provider for downstream services. Services
redirect users to /sso/authorize, MCIAS handles login (password, TOTP,
or passkey), then redirects back with an authorization code that the
service exchanges for a JWT via POST /v1/sso/token.

- Add SSO client registry to config (client_id, redirect_uri,
  service_name, tags) with validation
- Add internal/sso package: authorization code and session stores
  using sync.Map with TTL, single-use LoadAndDelete, cleanup goroutines
- Add GET /sso/authorize endpoint (validates client, creates session,
  redirects to /login?sso=<nonce>)
- Add POST /v1/sso/token endpoint (exchanges code for JWT with policy
  evaluation using client's service_name/tags from config)
- Thread SSO nonce through password→TOTP and WebAuthn login flows
- Update login.html, totp_step.html, and webauthn.js for SSO nonce
  passthrough

Security:
- Authorization codes are 256-bit random, single-use, 60-second TTL
- redirect_uri validated as exact match against registered config
- Policy context comes from MCIAS config, not the calling service
- SSO sessions are server-side only; nonce is the sole client-visible value
- WebAuthn SSO returns redirect URL as JSON (not HTTP redirect) for JS compat

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:21:48 -07:00
5b5e1a7ed6 Use mcdsl/terminal for all password prompts
Replace direct golang.org/x/term calls with mcdsl/terminal.ReadPassword
across mciasctl (6 sites), mciasgrpcctl (1 site), and mciasdb (1 site).
Aligns with the new CLI security standard in engineering-standards.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:40:11 -07:00
e4220b840e flake: install shell completions for both mciasctl and mciasgrpcctl
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:49:10 -07:00
cff7276293 mciasctl: convert from flag to cobra
Adds shell completion support (zsh, bash, fish) via cobra's built-in
completion command. All existing behavior and security measures are
preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:48:24 -07:00
be3bc807b7 mciasgrpcctl: convert from flag to cobra
Adds shell completion support (zsh, bash, fish) via cobra's built-in
completion command. All existing behavior and security measures are
preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:48:02 -07:00
ead32f72f8 Add MCR registry tagging and push target to Makefile
Add MCR variable. Replace local mcias:VERSION tags with MCR registry
URL. Remove :latest tag. Add push target.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:32:21 -07:00
d7d80c0f25 Bump flake.nix version to match latest tag
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:16:34 -07:00
41d01edfb4 Migrate module path from kyle/ to mc/ org
All import paths updated from git.wntrmute.dev/kyle/mcias to
git.wntrmute.dev/mc/mcias to match the Gitea organization.
Includes main module and clients/go submodule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:03:46 -07:00
9b521f3d99 Add MCP deployment note to runbook
- MCIAS is not managed by MCP due to circular dependency
- Points operators to existing systemd deployment sections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:09:23 -07:00
115f23a3ea Add Nix flake for mciasctl and mciasgrpcctl
Vendor dependencies and expose control program binaries via
nix build. Uses nixpkgs-unstable for Go 1.26 support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:01:21 -07:00
2662 changed files with 6821284 additions and 1698 deletions

View File

@@ -1377,7 +1377,7 @@ Error types exposed by every library:
#### Go (`clients/go/`)
- Module: `git.wntrmute.dev/kyle/mcias/clients/go`
- Module: `git.wntrmute.dev/mc/mcias/clients/go`
- Package: `mciasgoclient`
- HTTP: `net/http` with custom `*tls.Config` for CA cert
- Token state: guarded by `sync.RWMutex`

View File

@@ -381,7 +381,7 @@ expose the same API surface:
| Language | Location | Install |
|----------|----------|---------|
| Go | `clients/go/` | `go get git.wntrmute.dev/kyle/mcias/clients/go` |
| Go | `clients/go/` | `go get git.wntrmute.dev/mc/mcias/clients/go` |
| Python | `clients/python/` | `pip install ./clients/python` |
| Rust | `clients/rust/` | `cargo add mcias-client` |
| Common Lisp | `clients/lisp/` | ASDF `mcias-client` |
@@ -389,7 +389,7 @@ expose the same API surface:
### Go
```go
import mcias "git.wntrmute.dev/kyle/mcias/clients/go"
import mcias "git.wntrmute.dev/mc/mcias/clients/go"
c, err := mcias.New("https://auth.example.com:8443", "/etc/mcias/server.crt", "")
if err != nil { ... }

View File

@@ -19,7 +19,8 @@
# ---------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------
MODULE := git.wntrmute.dev/kyle/mcias
MODULE := git.wntrmute.dev/mc/mcias
MCR := mcr.svc.mcp.metacircular.net:8443
BINARIES := mciassrv mciasctl mciasdb mciasgrpcctl
BIN_DIR := bin
MAN_DIR := man/man1
@@ -163,9 +164,12 @@ dist: man
# ---------------------------------------------------------------------------
# docker — build the Docker image
# ---------------------------------------------------------------------------
.PHONY: docker
.PHONY: docker push
docker:
docker build --force-rm -t mcias:$(VERSION) -t mcias:latest .
docker build --force-rm -t $(MCR)/mcias:$(VERSION) .
push: docker
docker push $(MCR)/mcias:$(VERSION)
# ---------------------------------------------------------------------------
# docker-clean — remove local mcias Docker images

View File

@@ -21,7 +21,7 @@ features implemented beyond the original plan scope.
### Step 0.1: Go module and dependency setup
**Acceptance criteria:**
- `go.mod` exists with module path `git.wntrmute.dev/kyle/mcias`
- `go.mod` exists with module path `git.wntrmute.dev/mc/mcias`
- Required dependencies declared: `modernc.org/sqlite` (CGo-free SQLite),
`golang.org/x/crypto` (Argon2, Ed25519 helpers), `github.com/golang-jwt/jwt/v5`,
`github.com/pelletier/go-toml/v2`, `github.com/google/uuid`,
@@ -543,7 +543,7 @@ implementation notes.
### Step 9.2: Go client library
**Acceptance criteria:**
- `clients/go/` — Go module `git.wntrmute.dev/kyle/mcias/clients/go`
- `clients/go/` — Go module `git.wntrmute.dev/mc/mcias/clients/go`
- Package `mciasgoclient` exposes the canonical API surface from Step 9.1
- Uses `net/http` with `crypto/tls`; custom CA cert supported via `x509.CertPool`
- Token stored in-memory; `Client.Token()` accessor returns current token

View File

@@ -17,7 +17,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the technical design and
**Prerequisites:** Go 1.26+, a C compiler (required by modernc.org/sqlite).
```sh
git clone https://git.wntrmute.dev/kyle/mcias
git clone https://git.wntrmute.dev/mc/mcias
cd mcias
make build # produces bin/mciassrv, other binaries
sudo make install

View File

@@ -461,6 +461,19 @@ See `dist/mcias.conf.docker.example` for the full annotated Docker config.
---
## MCP Deployment
MCIAS is **not** managed by MCP and does not run on rift. Because MCIAS is the
authentication root for the entire platform — including MCP itself — running it
under MCP would create a circular dependency. Instead, MCIAS runs as a systemd
service on a separate VPS (`svc.metacircular.net`).
All deployment, upgrades, and operational tasks use systemd directly on the VPS.
See the [Installation](#installation), [Routine Operations](#routine-operations),
and [Upgrading](#upgrading) sections above for the relevant procedures.
---
## Troubleshooting
### Server fails to start: "open database"

View File

@@ -29,7 +29,7 @@ set_pg_creds(account_id, host, port, database, username, password) → void
| `MciasConflictError` | 409 | Conflict (e.g. duplicate username) |
| `MciasServerError` | 5xx | Unexpected server error |
`testdata/` contains canonical JSON response fixtures shared across language tests.
- `go/` — Go module `git.wntrmute.dev/kyle/mcias/clients/go`
- `go/` — Go module `git.wntrmute.dev/mc/mcias/clients/go`
- `rust/` — Rust crate `mcias-client`
- `lisp/` — ASDF system `mcias-client`
- `python/` — Python package `mcias_client`

View File

@@ -9,13 +9,13 @@ Go client library for the [MCIAS](../../README.md) identity and access managemen
## Installation
```sh
go get git.wntrmute.dev/kyle/mcias/clients/go
go get git.wntrmute.dev/mc/mcias/clients/go
```
## Quick Start
```go
import "git.wntrmute.dev/kyle/mcias/clients/go/mcias"
import "git.wntrmute.dev/mc/mcias/clients/go/mcias"
// Connect to the MCIAS server.
client, err := mcias.New("https://auth.example.com", mcias.Options{})

View File

@@ -11,7 +11,7 @@ import (
"strings"
"testing"
mcias "git.wntrmute.dev/kyle/mcias/clients/go"
mcias "git.wntrmute.dev/mc/mcias/clients/go"
)
// ---------------------------------------------------------------------------

View File

@@ -1,3 +1,3 @@
module git.wntrmute.dev/kyle/mcias/clients/go
module git.wntrmute.dev/mc/mcias/clients/go
go 1.21

View File

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,10 @@ import (
"os"
"strings"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/model"
"golang.org/x/term"
"git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcdsl/terminal"
)
func (t *tool) runAccount(args []string) {
@@ -233,20 +234,14 @@ func (t *tool) accountResetTOTP(args []string) {
// readPassword reads a password from the terminal without echo.
// Falls back to a regular line read if stdin is not a terminal (e.g. in tests).
func readPassword(prompt string) (string, error) {
pw, err := terminal.ReadPassword(prompt)
if err == nil {
return pw, nil
}
// Fallback for piped input (e.g. tests).
fmt.Fprint(os.Stderr, prompt)
fd := int(os.Stdin.Fd()) //nolint:gosec // G115: file descriptors are non-negative and fit in int on all supported platforms
if term.IsTerminal(fd) {
pw, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr) // newline after hidden input
if err != nil {
return "", fmt.Errorf("read password from terminal: %w", err)
}
return string(pw), nil
}
// Not a terminal: read a plain line (for piped input in tests).
var line string
_, err := fmt.Fscanln(os.Stdin, &line)
if err != nil {
if _, err := fmt.Fscanln(os.Stdin, &line); err != nil {
return "", fmt.Errorf("read password: %w", err)
}
return line, nil

View File

@@ -7,8 +7,8 @@ import (
"os"
"time"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
)
func (t *tool) runAudit(args []string) {

View File

@@ -49,9 +49,9 @@ import (
"fmt"
"os"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/db"
)
func main() {

View File

@@ -9,9 +9,9 @@ import (
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
)
// newTestTool creates a tool backed by an in-memory SQLite database with a

View File

@@ -6,8 +6,8 @@ import (
"fmt"
"os"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/db"
)
func (t *tool) runPGCreds(args []string) {

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"os"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/db"
)
// runRekey re-encrypts all secrets under a new passphrase-derived master key.

View File

@@ -4,7 +4,7 @@ import (
"flag"
"fmt"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/db"
)
func (t *tool) runSchema(args []string) {

View File

@@ -5,8 +5,8 @@ import (
"fmt"
"path/filepath"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/mc/mcias/internal/db"
)
// runSnapshot handles the "snapshot" command.

File diff suppressed because it is too large Load Diff

View File

@@ -31,12 +31,12 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/grpcserver"
"git.wntrmute.dev/kyle/mcias/internal/server"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/grpcserver"
"git.wntrmute.dev/mc/mcias/internal/server"
"git.wntrmute.dev/mc/mcias/internal/vault"
)
func main() {

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1774273680,
"narHash": "sha256-a++tZ1RQsDb1I0NHrFwdGuRlR5TORvCEUksM459wKUA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "fdc7b8f7b30fdbedec91b71ed82f36e1637483ed",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

45
flake.nix Normal file
View File

@@ -0,0 +1,45 @@
{
description = "mcias - Metacircular Identity and Access Service";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
};
outputs =
{ self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
version = "1.8.0";
in
{
packages.${system} = {
default = pkgs.buildGoModule {
pname = "mciasctl";
inherit version;
src = ./.;
vendorHash = null;
subPackages = [
"cmd/mciasctl"
"cmd/mciasgrpcctl"
];
ldflags = [
"-s"
"-w"
"-X main.version=${version}"
];
postInstall = ''
mkdir -p $out/share/zsh/site-functions
mkdir -p $out/share/bash-completion/completions
mkdir -p $out/share/fish/vendor_completions.d
$out/bin/mciasctl completion zsh > $out/share/zsh/site-functions/_mciasctl
$out/bin/mciasctl completion bash > $out/share/bash-completion/completions/mciasctl
$out/bin/mciasctl completion fish > $out/share/fish/vendor_completions.d/mciasctl.fish
$out/bin/mciasgrpcctl completion zsh > $out/share/zsh/site-functions/_mciasgrpcctl
$out/bin/mciasgrpcctl completion bash > $out/share/bash-completion/completions/mciasgrpcctl
$out/bin/mciasgrpcctl completion fish > $out/share/fish/vendor_completions.d/mciasgrpcctl.fish
'';
};
};
};
}

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.20.3
// protoc v6.32.1
// source: mcias/v1/account.proto
package mciasv1
@@ -1080,7 +1080,7 @@ const file_mcias_v1_account_proto_rawDesc = "" +
"\n" +
"GetPGCreds\x12\x1b.mcias.v1.GetPGCredsRequest\x1a\x1c.mcias.v1.GetPGCredsResponse\x12G\n" +
"\n" +
"SetPGCreds\x12\x1b.mcias.v1.SetPGCredsRequest\x1a\x1c.mcias.v1.SetPGCredsResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
"SetPGCreds\x12\x1b.mcias.v1.SetPGCredsRequest\x1a\x1c.mcias.v1.SetPGCredsResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var (
file_mcias_v1_account_proto_rawDescOnce sync.Once

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3
// - protoc v6.32.1
// source: mcias/v1/account.proto
package mciasv1

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.20.3
// protoc v6.32.1
// source: mcias/v1/admin.proto
package mciasv1
@@ -238,7 +238,7 @@ const file_mcias_v1_admin_proto_rawDesc = "" +
"\x01x\x18\x05 \x01(\tR\x01x2\x9a\x01\n" +
"\fAdminService\x12;\n" +
"\x06Health\x12\x17.mcias.v1.HealthRequest\x1a\x18.mcias.v1.HealthResponse\x12M\n" +
"\fGetPublicKey\x12\x1d.mcias.v1.GetPublicKeyRequest\x1a\x1e.mcias.v1.GetPublicKeyResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
"\fGetPublicKey\x12\x1d.mcias.v1.GetPublicKeyRequest\x1a\x1e.mcias.v1.GetPublicKeyResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var (
file_mcias_v1_admin_proto_rawDescOnce sync.Once

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3
// - protoc v6.32.1
// source: mcias/v1/admin.proto
package mciasv1

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.20.3
// protoc v6.32.1
// source: mcias/v1/auth.proto
package mciasv1
@@ -919,7 +919,7 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
"\n" +
"RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponse\x12n\n" +
"\x17ListWebAuthnCredentials\x12(.mcias.v1.ListWebAuthnCredentialsRequest\x1a).mcias.v1.ListWebAuthnCredentialsResponse\x12q\n" +
"\x18RemoveWebAuthnCredential\x12).mcias.v1.RemoveWebAuthnCredentialRequest\x1a*.mcias.v1.RemoveWebAuthnCredentialResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
"\x18RemoveWebAuthnCredential\x12).mcias.v1.RemoveWebAuthnCredentialRequest\x1a*.mcias.v1.RemoveWebAuthnCredentialResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var (
file_mcias_v1_auth_proto_rawDescOnce sync.Once

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3
// - protoc v6.32.1
// source: mcias/v1/auth.proto
package mciasv1

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.20.3
// protoc v6.32.1
// source: mcias/v1/common.proto
package mciasv1
@@ -349,7 +349,7 @@ const file_mcias_v1_common_proto_rawDesc = "" +
"\x04port\x18\x05 \x01(\x05R\x04port\"5\n" +
"\x05Error\x12\x18\n" +
"\amessage\x18\x01 \x01(\tR\amessage\x12\x12\n" +
"\x04code\x18\x02 \x01(\tR\x04codeB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
"\x04code\x18\x02 \x01(\tR\x04codeB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var (
file_mcias_v1_common_proto_rawDescOnce sync.Once

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.20.3
// protoc v6.32.1
// source: mcias/v1/policy.proto
package mciasv1
@@ -703,7 +703,7 @@ const file_mcias_v1_policy_proto_rawDesc = "" +
"\x10CreatePolicyRule\x12!.mcias.v1.CreatePolicyRuleRequest\x1a\".mcias.v1.CreatePolicyRuleResponse\x12P\n" +
"\rGetPolicyRule\x12\x1e.mcias.v1.GetPolicyRuleRequest\x1a\x1f.mcias.v1.GetPolicyRuleResponse\x12Y\n" +
"\x10UpdatePolicyRule\x12!.mcias.v1.UpdatePolicyRuleRequest\x1a\".mcias.v1.UpdatePolicyRuleResponse\x12Y\n" +
"\x10DeletePolicyRule\x12!.mcias.v1.DeletePolicyRuleRequest\x1a\".mcias.v1.DeletePolicyRuleResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
"\x10DeletePolicyRule\x12!.mcias.v1.DeletePolicyRuleRequest\x1a\".mcias.v1.DeletePolicyRuleResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var (
file_mcias_v1_policy_proto_rawDescOnce sync.Once

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3
// - protoc v6.32.1
// source: mcias/v1/policy.proto
package mciasv1

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.20.3
// protoc v6.32.1
// source: mcias/v1/token.proto
package mciasv1
@@ -346,7 +346,7 @@ const file_mcias_v1_token_proto_rawDesc = "" +
"\fTokenService\x12P\n" +
"\rValidateToken\x12\x1e.mcias.v1.ValidateTokenRequest\x1a\x1f.mcias.v1.ValidateTokenResponse\x12\\\n" +
"\x11IssueServiceToken\x12\".mcias.v1.IssueServiceTokenRequest\x1a#.mcias.v1.IssueServiceTokenResponse\x12J\n" +
"\vRevokeToken\x12\x1c.mcias.v1.RevokeTokenRequest\x1a\x1d.mcias.v1.RevokeTokenResponseB2Z0git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1b\x06proto3"
"\vRevokeToken\x12\x1c.mcias.v1.RevokeTokenRequest\x1a\x1d.mcias.v1.RevokeTokenResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var (
file_mcias_v1_token_proto_rawDescOnce sync.Once

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3
// - protoc v6.32.1
// source: mcias/v1/token.proto
package mciasv1

26
go.mod
View File

@@ -1,38 +1,40 @@
module git.wntrmute.dev/kyle/mcias
module git.wntrmute.dev/mc/mcias
go 1.26.0
require (
git.wntrmute.dev/mc/mcdsl v1.4.0
github.com/go-webauthn/webauthn v0.16.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pelletier/go-toml/v2 v2.3.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.49.0
golang.org/x/term v0.41.0
google.golang.org/grpc v1.74.2
google.golang.org/protobuf v1.36.7
modernc.org/sqlite v1.46.1
google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.47.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/webauthn v0.16.1 // indirect
github.com/go-webauthn/x v0.2.2 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
modernc.org/libc v1.67.6 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

82
go.sum
View File

@@ -1,3 +1,8 @@
git.wntrmute.dev/mc/mcdsl v1.4.0 h1:PsEIyskcjBduwHSRwNB/U/uSeU/cv3C8MVr0SRjBRLg=
git.wntrmute.dev/mc/mcdsl v1.4.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -24,46 +29,56 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
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/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
@@ -79,28 +94,31 @@ golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
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=
@@ -109,8 +127,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -29,7 +29,7 @@ import (
"golang.org/x/crypto/argon2"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/crypto"
)
// ErrInvalidCredentials is returned for any authentication failure.

View File

@@ -22,6 +22,24 @@ type Config struct { //nolint:govet // fieldalignment: TOML section order is mor
Tokens TokensConfig `toml:"tokens"`
Argon2 Argon2Config `toml:"argon2"`
WebAuthn WebAuthnConfig `toml:"webauthn"`
SSO SSOConfig `toml:"sso"`
}
// SSOConfig holds registered SSO clients that may use the authorization code
// flow to authenticate users via MCIAS. Omitting the [sso] section or leaving
// clients empty disables SSO.
type SSOConfig struct {
Clients []SSOClient `toml:"clients"`
}
// SSOClient is a registered relying-party application that may redirect users
// to MCIAS for login. The redirect_uri is validated as an exact match (no
// wildcards) to prevent open-redirect attacks.
type SSOClient struct {
ClientID string `toml:"client_id"` // unique identifier (e.g. "mcr")
RedirectURI string `toml:"redirect_uri"` // exact callback URL, https required
ServiceName string `toml:"service_name"` // passed to policy engine on login
Tags []string `toml:"tags"` // passed to policy engine on login
}
// WebAuthnConfig holds FIDO2/WebAuthn settings. Omitting the entire [webauthn]
@@ -246,9 +264,48 @@ func (c *Config) validate() error {
}
}
// SSO clients — if any are configured, each must have a unique client_id,
// a non-empty redirect_uri with the https:// scheme, and a non-empty
// service_name.
seen := make(map[string]bool, len(c.SSO.Clients))
for i, cl := range c.SSO.Clients {
prefix := fmt.Sprintf("sso.clients[%d]", i)
if cl.ClientID == "" {
errs = append(errs, fmt.Errorf("%s: client_id is required", prefix))
} else if seen[cl.ClientID] {
errs = append(errs, fmt.Errorf("%s: duplicate client_id %q", prefix, cl.ClientID))
} else {
seen[cl.ClientID] = true
}
if cl.RedirectURI == "" {
errs = append(errs, fmt.Errorf("%s: redirect_uri is required", prefix))
} else if !strings.HasPrefix(cl.RedirectURI, "https://") {
errs = append(errs, fmt.Errorf("%s: redirect_uri must use the https:// scheme (got %q)", prefix, cl.RedirectURI))
}
if cl.ServiceName == "" {
errs = append(errs, fmt.Errorf("%s: service_name is required", prefix))
}
}
return errors.Join(errs...)
}
// SSOClient looks up a registered SSO client by client_id.
// Returns nil if no client with that ID is registered.
func (c *Config) SSOClient(clientID string) *SSOClient {
for i := range c.SSO.Clients {
if c.SSO.Clients[i].ClientID == clientID {
return &c.SSO.Clients[i]
}
}
return nil
}
// SSOEnabled reports whether any SSO clients are registered.
func (c *Config) SSOEnabled() bool {
return len(c.SSO.Clients) > 0
}
// DefaultExpiry returns the configured default token expiry duration.
func (c *Config) DefaultExpiry() time.Duration { return c.Tokens.DefaultExpiry.Duration }

View File

@@ -244,6 +244,153 @@ func TestTrustedProxyValidation(t *testing.T) {
}
}
func TestSSOClientValidation(t *testing.T) {
tests := []struct {
name string
extra string
wantErr bool
}{
{
name: "valid single client",
extra: `
[[sso.clients]]
client_id = "mcr"
redirect_uri = "https://mcr.example.com/sso/callback"
service_name = "mcr"
tags = ["env:restricted"]
`,
wantErr: false,
},
{
name: "valid multiple clients",
extra: `
[[sso.clients]]
client_id = "mcr"
redirect_uri = "https://mcr.example.com/sso/callback"
service_name = "mcr"
[[sso.clients]]
client_id = "mcat"
redirect_uri = "https://mcat.example.com/sso/callback"
service_name = "mcat"
`,
wantErr: false,
},
{
name: "missing client_id",
extra: `
[[sso.clients]]
redirect_uri = "https://mcr.example.com/sso/callback"
service_name = "mcr"
`,
wantErr: true,
},
{
name: "missing redirect_uri",
extra: `
[[sso.clients]]
client_id = "mcr"
service_name = "mcr"
`,
wantErr: true,
},
{
name: "http redirect_uri rejected",
extra: `
[[sso.clients]]
client_id = "mcr"
redirect_uri = "http://mcr.example.com/sso/callback"
service_name = "mcr"
`,
wantErr: true,
},
{
name: "missing service_name",
extra: `
[[sso.clients]]
client_id = "mcr"
redirect_uri = "https://mcr.example.com/sso/callback"
`,
wantErr: true,
},
{
name: "duplicate client_id",
extra: `
[[sso.clients]]
client_id = "mcr"
redirect_uri = "https://mcr.example.com/sso/callback"
service_name = "mcr"
[[sso.clients]]
client_id = "mcr"
redirect_uri = "https://other.example.com/sso/callback"
service_name = "mcr2"
`,
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
path := writeTempConfig(t, validConfig()+tc.extra)
_, err := Load(path)
if tc.wantErr && err == nil {
t.Error("expected validation error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestSSOClientLookup(t *testing.T) {
path := writeTempConfig(t, validConfig()+`
[[sso.clients]]
client_id = "mcr"
redirect_uri = "https://mcr.example.com/sso/callback"
service_name = "mcr"
tags = ["env:restricted"]
`)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
cl := cfg.SSOClient("mcr")
if cl == nil {
t.Fatal("SSOClient(mcr) returned nil")
}
if cl.RedirectURI != "https://mcr.example.com/sso/callback" {
t.Errorf("RedirectURI = %q", cl.RedirectURI)
}
if cl.ServiceName != "mcr" {
t.Errorf("ServiceName = %q", cl.ServiceName)
}
if len(cl.Tags) != 1 || cl.Tags[0] != "env:restricted" {
t.Errorf("Tags = %v", cl.Tags)
}
if cfg.SSOClient("nonexistent") != nil {
t.Error("SSOClient(nonexistent) should return nil")
}
if !cfg.SSOEnabled() {
t.Error("SSOEnabled() should return true")
}
}
func TestSSODisabledByDefault(t *testing.T) {
path := writeTempConfig(t, validConfig())
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.SSOEnabled() {
t.Error("SSOEnabled() should return false with no clients")
}
}
func TestDurationParsing(t *testing.T) {
var d duration
if err := d.UnmarshalText([]byte("1h30m")); err != nil {

View File

@@ -6,7 +6,7 @@ import (
"fmt"
"time"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/model"
"github.com/google/uuid"
)

View File

@@ -5,7 +5,7 @@ import (
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/model"
)
// openTestDB opens an in-memory SQLite database for testing.

View File

@@ -4,7 +4,7 @@ import (
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/model"
)
// openTestDB is defined in db_test.go in this package; reused here.

View File

@@ -6,7 +6,7 @@ import (
"fmt"
"time"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/model"
)
// ListCredentialedAccountIDs returns the set of account IDs that already have

View File

@@ -6,7 +6,7 @@ import (
"fmt"
"time"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/model"
)
// policyRuleCols is the column list for all policy rule SELECT queries.

View File

@@ -5,7 +5,7 @@ import (
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/model"
)
func TestCreateAndGetPolicyRule(t *testing.T) {

View File

@@ -3,7 +3,7 @@ package db
import (
"testing"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/model"
)
func TestGetAccountTags_Empty(t *testing.T) {

View File

@@ -5,7 +5,7 @@ import (
"errors"
"fmt"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/model"
)
// CreateWebAuthnCredential inserts a new WebAuthn credential record.

View File

@@ -4,7 +4,7 @@ import (
"errors"
"testing"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/model"
)
func TestWebAuthnCRUD(t *testing.T) {

View File

@@ -11,11 +11,11 @@ import (
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/validate"
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/validate"
)
type accountServiceServer struct {

View File

@@ -9,7 +9,7 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
)
type adminServiceServer struct {

View File

@@ -13,12 +13,12 @@ import (
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/token"
)
type authServiceServer struct {

View File

@@ -9,10 +9,10 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
)
type credentialServiceServer struct {

View File

@@ -30,11 +30,11 @@ import (
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/mc/mcias/internal/vault"
)
// contextKey is the unexported context key type for this package.

View File

@@ -24,13 +24,13 @@ import (
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/mc/mcias/internal/vault"
)
const (

View File

@@ -13,10 +13,10 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy"
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/policy"
)
type policyServiceServer struct {

View File

@@ -10,10 +10,10 @@ import (
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/token"
)
type tokenServiceServer struct {

View File

@@ -11,8 +11,8 @@ import (
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/model"
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/mc/mcias/internal/model"
)
// ListWebAuthnCredentials returns metadata for an account's WebAuthn credentials.

View File

@@ -23,10 +23,10 @@ import (
"sync"
"time"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/policy"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/policy"
"git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/mc/mcias/internal/vault"
)
// contextKey is the unexported type for context keys in this package, preventing

View File

@@ -12,10 +12,10 @@ import (
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/mc/mcias/internal/vault"
)
func generateTestKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {

View File

@@ -218,6 +218,9 @@ const (
EventWebAuthnRemoved = "webauthn_removed"
EventWebAuthnLoginOK = "webauthn_login_ok"
EventWebAuthnLoginFail = "webauthn_login_fail"
EventSSOAuthorize = "sso_authorize"
EventSSOLoginOK = "sso_login_ok"
)
// ServiceAccountDelegate records that a specific account has been granted

View File

@@ -8,10 +8,10 @@ import (
"strconv"
"time"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/policy"
)
// ---- Tag endpoints ----

View File

@@ -0,0 +1,141 @@
package server
import (
"net/http"
"git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/policy"
"git.wntrmute.dev/mc/mcias/internal/sso"
"git.wntrmute.dev/mc/mcias/internal/token"
)
// ssoTokenRequest is the request body for POST /v1/sso/token.
type ssoTokenRequest struct {
Code string `json:"code"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
}
// handleSSOTokenExchange exchanges an SSO authorization code for a JWT token.
//
// Security design:
// - The authorization code is single-use (consumed via LoadAndDelete).
// - The client_id and redirect_uri must match the values stored when the code
// was issued, preventing a stolen code from being exchanged by a different
// service.
// - Policy evaluation uses the service_name and tags from the registered SSO
// client config (not from the request), preventing identity spoofing.
// - The code expires after 60 seconds to limit the interception window.
func (s *Server) handleSSOTokenExchange(w http.ResponseWriter, r *http.Request) {
var req ssoTokenRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Code == "" || req.ClientID == "" || req.RedirectURI == "" {
middleware.WriteError(w, http.StatusBadRequest, "code, client_id, and redirect_uri are required", "bad_request")
return
}
// Consume the authorization code (single-use).
ac, ok := sso.Consume(req.Code)
if !ok {
middleware.WriteError(w, http.StatusUnauthorized, "invalid or expired authorization code", "invalid_code")
return
}
// Security: verify client_id and redirect_uri match the stored values.
if ac.ClientID != req.ClientID || ac.RedirectURI != req.RedirectURI {
s.logger.Warn("sso: token exchange parameter mismatch",
"expected_client", ac.ClientID, "got_client", req.ClientID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid or expired authorization code", "invalid_code")
return
}
// Look up the registered SSO client for policy context.
client := s.cfg.SSOClient(req.ClientID)
if client == nil {
// Should not happen if the authorize endpoint validated, but defend in depth.
middleware.WriteError(w, http.StatusUnauthorized, "unknown client", "invalid_code")
return
}
// Load account.
acct, err := s.db.GetAccountByID(ac.AccountID)
if err != nil {
s.logger.Error("sso: load account for token exchange", "error", err, "account_id", ac.AccountID)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if acct.Status != model.AccountStatusActive {
middleware.WriteError(w, http.StatusForbidden, "account is not active", "account_inactive")
return
}
// Load roles for policy evaluation and expiry decision.
roles, err := s.db.GetRoles(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
// Policy evaluation with the SSO client's service_name and tags.
{
input := policy.PolicyInput{
Subject: acct.UUID,
AccountType: string(acct.AccountType),
Roles: roles,
Action: policy.ActionLogin,
Resource: policy.Resource{
ServiceName: client.ServiceName,
Tags: client.Tags,
},
}
if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny {
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil,
audit.JSON("reason", "policy_deny", "service_name", client.ServiceName, "via", "sso"))
middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied")
return
}
}
// Determine expiry.
expiry := s.cfg.DefaultExpiry()
for _, rol := range roles {
if rol == "admin" {
expiry = s.cfg.AdminExpiry()
break
}
}
privKey, err := s.vault.PrivKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
tokenStr, claims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
if err != nil {
s.logger.Error("sso: issue token", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if err := s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
s.logger.Error("sso: track token", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s.writeAudit(r, model.EventSSOLoginOK, &acct.ID, nil,
audit.JSON("jti", claims.JTI, "client_id", client.ClientID))
s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
audit.JSON("jti", claims.JTI, "via", "sso"))
writeJSON(w, http.StatusOK, loginResponse{
Token: tokenStr,
ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
})
}

View File

@@ -23,14 +23,14 @@ import (
"github.com/go-webauthn/webauthn/protocol"
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy"
"git.wntrmute.dev/kyle/mcias/internal/token"
mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn"
"git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/policy"
"git.wntrmute.dev/mc/mcias/internal/token"
mciaswebauthn "git.wntrmute.dev/mc/mcias/internal/webauthn"
)
const (

View File

@@ -21,19 +21,19 @@ import (
"strings"
"time"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/ui"
"git.wntrmute.dev/kyle/mcias/internal/validate"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/kyle/mcias/web"
"git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/policy"
"git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/mc/mcias/internal/ui"
"git.wntrmute.dev/mc/mcias/internal/validate"
"git.wntrmute.dev/mc/mcias/internal/vault"
"git.wntrmute.dev/mc/mcias/web"
)
// Server holds the dependencies injected into all handlers.
@@ -215,6 +215,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /v1/health", s.handleHealth)
mux.HandleFunc("GET /v1/keys/public", s.handlePublicKey)
mux.Handle("POST /v1/auth/login", loginRateLimit(http.HandlerFunc(s.handleLogin)))
mux.Handle("POST /v1/sso/token", loginRateLimit(http.HandlerFunc(s.handleSSOTokenExchange)))
mux.Handle("POST /v1/token/validate", loginRateLimit(http.HandlerFunc(s.handleTokenValidate)))
// API documentation: Swagger UI at /docs and raw spec at /docs/openapi.yaml.

View File

@@ -19,13 +19,13 @@ import (
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/policy"
"git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/mc/mcias/internal/vault"
)
// generateTOTPCode computes a valid RFC 6238 TOTP code for the current time

View File

@@ -4,10 +4,10 @@ package server
import (
"net/http"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/vault"
)
// unsealRequest is the request body for POST /v1/vault/unseal.

View File

@@ -7,7 +7,7 @@ import (
"strings"
"testing"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/mc/mcias/internal/vault"
)
func TestHandleHealthSealed(t *testing.T) {

91
internal/sso/session.go Normal file
View File

@@ -0,0 +1,91 @@
package sso
import (
"fmt"
"sync"
"time"
"git.wntrmute.dev/mc/mcias/internal/crypto"
)
const (
sessionTTL = 5 * time.Minute
sessionBytes = 16 // 128 bits of entropy for the nonce
)
// Session holds the SSO parameters between /sso/authorize and login completion.
// The nonce is embedded as a hidden form field in the login page and carried
// through the multi-step login flow (password → TOTP, or WebAuthn).
type Session struct { //nolint:govet // fieldalignment: field order matches logical grouping
ClientID string
RedirectURI string
State string
ExpiresAt time.Time
}
// pendingSessions stores SSO sessions created at /sso/authorize.
var pendingSessions sync.Map //nolint:gochecknoglobals
func init() {
go cleanupSessions()
}
func cleanupSessions() {
ticker := time.NewTicker(cleanupPeriod)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
pendingSessions.Range(func(key, value any) bool {
s, ok := value.(*Session)
if !ok || now.After(s.ExpiresAt) {
pendingSessions.Delete(key)
}
return true
})
}
}
// StoreSession creates and stores a new SSO session, returning the hex-encoded
// nonce that should be embedded in the login form.
func StoreSession(clientID, redirectURI, state string) (string, error) {
raw, err := crypto.RandomBytes(sessionBytes)
if err != nil {
return "", fmt.Errorf("sso: generate session nonce: %w", err)
}
nonce := fmt.Sprintf("%x", raw)
pendingSessions.Store(nonce, &Session{
ClientID: clientID,
RedirectURI: redirectURI,
State: state,
ExpiresAt: time.Now().Add(sessionTTL),
})
return nonce, nil
}
// ConsumeSession retrieves and deletes an SSO session by nonce.
// Returns the Session and true if valid, or (nil, false) if unknown or expired.
func ConsumeSession(nonce string) (*Session, bool) {
v, ok := pendingSessions.LoadAndDelete(nonce)
if !ok {
return nil, false
}
s, ok2 := v.(*Session)
if !ok2 || time.Now().After(s.ExpiresAt) {
return nil, false
}
return s, true
}
// GetSession retrieves an SSO session without consuming it (for read-only checks
// during multi-step login). Returns nil if unknown or expired.
func GetSession(nonce string) *Session {
v, ok := pendingSessions.Load(nonce)
if !ok {
return nil
}
s, ok2 := v.(*Session)
if !ok2 || time.Now().After(s.ExpiresAt) {
return nil
}
return s
}

93
internal/sso/store.go Normal file
View File

@@ -0,0 +1,93 @@
// Package sso implements the authorization code store for the SSO redirect flow.
//
// MCIAS acts as the SSO provider: downstream services (MCR, MCAT, Metacrypt)
// redirect users to MCIAS for login, and MCIAS issues a short-lived, single-use
// authorization code that the service exchanges for a JWT token.
//
// Security design:
// - Authorization codes are 32 random bytes (256 bits), hex-encoded.
// - Codes are single-use: consumed via sync.Map LoadAndDelete on first exchange.
// - Codes expire after 60 seconds to limit the window for interception.
// - A background goroutine evicts expired codes every 5 minutes.
// - The code is bound to the client_id and redirect_uri presented at authorize
// time; the token exchange endpoint must verify both match.
package sso
import (
"fmt"
"sync"
"time"
"git.wntrmute.dev/mc/mcias/internal/crypto"
)
const (
codeTTL = 60 * time.Second
codeBytes = 32 // 256 bits of entropy
cleanupPeriod = 5 * time.Minute
)
// AuthCode is a pending authorization code awaiting exchange for a JWT.
type AuthCode struct { //nolint:govet // fieldalignment: field order matches logical grouping
ClientID string
RedirectURI string
State string
AccountID int64
ExpiresAt time.Time
}
// pendingCodes stores issued authorization codes awaiting exchange.
var pendingCodes sync.Map //nolint:gochecknoglobals
func init() {
go cleanupCodes()
}
func cleanupCodes() {
ticker := time.NewTicker(cleanupPeriod)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
pendingCodes.Range(func(key, value any) bool {
ac, ok := value.(*AuthCode)
if !ok || now.After(ac.ExpiresAt) {
pendingCodes.Delete(key)
}
return true
})
}
}
// Store creates and stores a new authorization code bound to the given
// client_id, redirect_uri, state, and account. Returns the hex-encoded code.
func Store(clientID, redirectURI, state string, accountID int64) (string, error) {
raw, err := crypto.RandomBytes(codeBytes)
if err != nil {
return "", fmt.Errorf("sso: generate authorization code: %w", err)
}
code := fmt.Sprintf("%x", raw)
pendingCodes.Store(code, &AuthCode{
ClientID: clientID,
RedirectURI: redirectURI,
State: state,
AccountID: accountID,
ExpiresAt: time.Now().Add(codeTTL),
})
return code, nil
}
// Consume retrieves and deletes an authorization code. Returns the AuthCode
// and true if the code was valid and not expired, or (nil, false) otherwise.
//
// Security: LoadAndDelete ensures single-use; the code cannot be replayed.
func Consume(code string) (*AuthCode, bool) {
v, ok := pendingCodes.LoadAndDelete(code)
if !ok {
return nil, false
}
ac, ok2 := v.(*AuthCode)
if !ok2 || time.Now().After(ac.ExpiresAt) {
return nil, false
}
return ac, true
}

132
internal/sso/store_test.go Normal file
View File

@@ -0,0 +1,132 @@
package sso
import (
"testing"
"time"
)
func TestStoreAndConsume(t *testing.T) {
code, err := Store("mcr", "https://mcr.example.com/cb", "state123", 42)
if err != nil {
t.Fatalf("Store: %v", err)
}
if code == "" {
t.Fatal("Store returned empty code")
}
ac, ok := Consume(code)
if !ok {
t.Fatal("Consume returned false for valid code")
}
if ac.ClientID != "mcr" {
t.Errorf("ClientID = %q, want %q", ac.ClientID, "mcr")
}
if ac.RedirectURI != "https://mcr.example.com/cb" {
t.Errorf("RedirectURI = %q", ac.RedirectURI)
}
if ac.State != "state123" {
t.Errorf("State = %q", ac.State)
}
if ac.AccountID != 42 {
t.Errorf("AccountID = %d, want 42", ac.AccountID)
}
}
func TestConsumeSingleUse(t *testing.T) {
code, err := Store("mcr", "https://mcr.example.com/cb", "s", 1)
if err != nil {
t.Fatalf("Store: %v", err)
}
if _, ok := Consume(code); !ok {
t.Fatal("first Consume should succeed")
}
if _, ok := Consume(code); ok {
t.Error("second Consume should fail (single-use)")
}
}
func TestConsumeUnknownCode(t *testing.T) {
if _, ok := Consume("nonexistent"); ok {
t.Error("Consume should fail for unknown code")
}
}
func TestConsumeExpiredCode(t *testing.T) {
code, err := Store("mcr", "https://mcr.example.com/cb", "s", 1)
if err != nil {
t.Fatalf("Store: %v", err)
}
// Manually expire the code.
v, loaded := pendingCodes.Load(code)
if !loaded {
t.Fatal("code not found in pendingCodes")
}
ac, ok := v.(*AuthCode)
if !ok {
t.Fatal("unexpected type in pendingCodes")
}
ac.ExpiresAt = time.Now().Add(-1 * time.Second)
if _, ok := Consume(code); ok {
t.Error("Consume should fail for expired code")
}
}
func TestStoreSessionAndConsume(t *testing.T) {
nonce, err := StoreSession("mcr", "https://mcr.example.com/cb", "state456")
if err != nil {
t.Fatalf("StoreSession: %v", err)
}
if nonce == "" {
t.Fatal("StoreSession returned empty nonce")
}
// GetSession should return it without consuming.
s := GetSession(nonce)
if s == nil {
t.Fatal("GetSession returned nil")
}
if s.ClientID != "mcr" {
t.Errorf("ClientID = %q", s.ClientID)
}
// Still available after GetSession.
s2, ok := ConsumeSession(nonce)
if !ok {
t.Fatal("ConsumeSession returned false")
}
if s2.State != "state456" {
t.Errorf("State = %q", s2.State)
}
// Consumed — should be gone.
if _, ok := ConsumeSession(nonce); ok {
t.Error("second ConsumeSession should fail")
}
if GetSession(nonce) != nil {
t.Error("GetSession should return nil after consume")
}
}
func TestConsumeSessionExpired(t *testing.T) {
nonce, err := StoreSession("mcr", "https://mcr.example.com/cb", "s")
if err != nil {
t.Fatalf("StoreSession: %v", err)
}
v, loaded := pendingSessions.Load(nonce)
if !loaded {
t.Fatal("session not found in pendingSessions")
}
sess, ok := v.(*Session)
if !ok {
t.Fatal("unexpected type in pendingSessions")
}
sess.ExpiresAt = time.Now().Add(-1 * time.Second)
if _, ok := ConsumeSession(nonce); ok {
t.Error("ConsumeSession should fail for expired session")
}
}

View File

@@ -3,7 +3,7 @@ package ui
import (
"context"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/mc/mcias/internal/token"
)
// uiContextKey is the unexported type for UI context values, preventing

View File

@@ -10,7 +10,7 @@ import (
"fmt"
"sync"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/mc/mcias/internal/vault"
)
// CSRFManager implements HMAC-signed Double-Submit Cookie CSRF protection.

View File

@@ -7,11 +7,11 @@ import (
"strconv"
"strings"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/validate"
"git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/validate"
)
// knownRoles lists the built-in roles shown as checkboxes in the roles editor.

View File

@@ -4,8 +4,8 @@ import (
"net/http"
"strconv"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
)
const auditPageSize = 50

View File

@@ -3,18 +3,19 @@ package ui
import (
"net/http"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/validate"
"git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/mc/mcias/internal/validate"
)
// handleLoginPage renders the login form.
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
u.render(w, "login", LoginData{
WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
SSONonce: r.URL.Query().Get("sso"),
})
}
@@ -97,6 +98,8 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
return
}
ssoNonce := r.FormValue("sso_nonce")
// TOTP required: issue a server-side nonce and show the TOTP step form.
// Security: the nonce replaces the password hidden field (F-02). The password
// is not stored anywhere after this point; only the account ID is retained.
@@ -110,11 +113,12 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
u.render(w, "totp_step", LoginData{
Username: username,
Nonce: nonce,
SSONonce: ssoNonce,
})
return
}
u.finishLogin(w, r, acct)
u.finishLogin(w, r, acct, ssoNonce)
}
// handleTOTPStep handles the second POST when totp_step=1 is set.
@@ -129,6 +133,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username") //nolint:gosec // body already limited by caller
nonce := r.FormValue("totp_nonce") //nolint:gosec // body already limited by caller
totpCode := r.FormValue("totp_code") //nolint:gosec // body already limited by caller
ssoNonce := r.FormValue("sso_nonce") //nolint:gosec // body already limited by caller
// Security: consume the nonce (single-use); reject if unknown or expired.
accountID, ok := u.consumeTOTPNonce(nonce)
@@ -172,6 +177,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
Error: "invalid TOTP code",
Username: username,
Nonce: newNonce,
SSONonce: ssoNonce,
})
return
}
@@ -189,15 +195,28 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
Error: "invalid TOTP code",
Username: username,
Nonce: newNonce,
SSONonce: ssoNonce,
})
return
}
u.finishLogin(w, r, acct)
u.finishLogin(w, r, acct, ssoNonce)
}
// finishLogin issues a JWT, sets the session cookie, and redirects to dashboard.
func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *model.Account) {
// When ssoNonce is non-empty, the login is part of an SSO redirect flow: instead
// of setting a session cookie, an authorization code is issued and the user is
// redirected back to the service's callback URL.
func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *model.Account, ssoNonce string) {
// SSO redirect flow: issue authorization code and redirect to service.
if ssoNonce != "" {
if callbackURL, ok := u.buildSSOCallback(r, ssoNonce, acct.ID); ok {
http.Redirect(w, r, callbackURL, http.StatusFound)
return
}
// SSO session expired/consumed — fall through to normal login.
}
// Determine token expiry based on admin role.
expiry := u.cfg.DefaultExpiry()
roles, err := u.db.GetRoles(acct.ID)

View File

@@ -3,8 +3,8 @@ package ui
import (
"net/http"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
)
// handleDashboard renders the main dashboard page. Admin users see account

View File

@@ -9,9 +9,9 @@ import (
"strings"
"time"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/policy"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/policy"
)
// ---- Policies page ----

View File

@@ -0,0 +1,84 @@
package ui
import (
"net/http"
"net/url"
"git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/sso"
)
// handleSSOAuthorize validates the SSO request parameters against registered
// clients, creates an SSO session, and redirects to /login with the SSO nonce.
//
// Security: the client_id and redirect_uri are validated against the MCIAS
// config (exact match). The state parameter is opaque and carried through
// unchanged. An SSO session is created server-side so the nonce is the only
// value embedded in the login form.
func (u *UIServer) handleSSOAuthorize(w http.ResponseWriter, r *http.Request) {
clientID := r.URL.Query().Get("client_id")
redirectURI := r.URL.Query().Get("redirect_uri")
state := r.URL.Query().Get("state")
if clientID == "" || redirectURI == "" || state == "" {
http.Error(w, "missing required parameters: client_id, redirect_uri, state", http.StatusBadRequest)
return
}
// Security: validate client_id against registered SSO clients.
client := u.cfg.SSOClient(clientID)
if client == nil {
u.logger.Warn("sso: unknown client_id", "client_id", clientID)
http.Error(w, "unknown client_id", http.StatusBadRequest)
return
}
// Security: redirect_uri must exactly match the registered URI to prevent
// open-redirect attacks.
if redirectURI != client.RedirectURI {
u.logger.Warn("sso: redirect_uri mismatch",
"client_id", clientID,
"expected", client.RedirectURI,
"got", redirectURI)
http.Error(w, "redirect_uri does not match registered URI", http.StatusBadRequest)
return
}
nonce, err := sso.StoreSession(clientID, redirectURI, state)
if err != nil {
u.logger.Error("sso: store session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
u.writeAudit(r, model.EventSSOAuthorize, nil, nil,
audit.JSON("client_id", clientID))
http.Redirect(w, r, "/login?sso="+url.QueryEscape(nonce), http.StatusFound)
}
// buildSSOCallback consumes the SSO session, generates an authorization code,
// and returns the callback URL with code and state parameters. Returns ("", false)
// if the SSO session is expired or already consumed.
//
// Security: the SSO session is consumed (single-use) and the authorization code
// is stored server-side for exchange via POST /v1/sso/token. The state parameter
// is carried through unchanged for the service to validate.
func (u *UIServer) buildSSOCallback(r *http.Request, ssoNonce string, accountID int64) (string, bool) {
sess, ok := sso.ConsumeSession(ssoNonce)
if !ok {
return "", false
}
code, err := sso.Store(sess.ClientID, sess.RedirectURI, sess.State, accountID)
if err != nil {
u.logger.Error("sso: store auth code", "error", err)
return "", false
}
u.writeAudit(r, model.EventSSOLoginOK, &accountID, nil,
audit.JSON("client_id", sess.ClientID))
return sess.RedirectURI + "?code=" + url.QueryEscape(code) + "&state=" + url.QueryEscape(sess.State), true
}

View File

@@ -9,10 +9,10 @@ import (
qrcode "github.com/skip2/go-qrcode"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/model"
)
// handleTOTPEnrollStart processes the password re-auth step and generates

View File

@@ -4,10 +4,10 @@ package ui
import (
"net/http"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/vault"
)
// UnsealData is the view model for the unseal page.

View File

@@ -12,12 +12,12 @@ import (
"github.com/go-webauthn/webauthn/protocol"
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/audit"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
mciaswebauthn "git.wntrmute.dev/kyle/mcias/internal/webauthn"
"git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/token"
mciaswebauthn "git.wntrmute.dev/mc/mcias/internal/webauthn"
)
const (
@@ -27,10 +27,11 @@ const (
)
// webauthnCeremony holds a pending WebAuthn ceremony.
type webauthnCeremony struct {
type webauthnCeremony struct { //nolint:govet // fieldalignment: field order matches logical grouping
expiresAt time.Time
session *libwebauthn.SessionData
accountID int64
ssoNonce string // non-empty when login is part of an SSO redirect flow
}
// pendingWebAuthnCeremonies stores in-flight WebAuthn ceremonies for the UI.
@@ -55,7 +56,7 @@ func cleanupUIWebAuthnCeremonies() {
}
}
func storeUICeremony(session *libwebauthn.SessionData, accountID int64) (string, error) {
func storeUICeremony(session *libwebauthn.SessionData, accountID int64, ssoNonce string) (string, error) {
raw, err := crypto.RandomBytes(webauthnNonceBytes)
if err != nil {
return "", fmt.Errorf("webauthn: generate ceremony nonce: %w", err)
@@ -64,6 +65,7 @@ func storeUICeremony(session *libwebauthn.SessionData, accountID int64) (string,
pendingUIWebAuthnCeremonies.Store(nonce, &webauthnCeremony{
session: session,
accountID: accountID,
ssoNonce: ssoNonce,
expiresAt: time.Now().Add(webauthnCeremonyTTL),
})
return nonce, nil
@@ -170,7 +172,7 @@ func (u *UIServer) handleWebAuthnBegin(w http.ResponseWriter, r *http.Request) {
return
}
nonce, err := storeUICeremony(session, acct.ID)
nonce, err := storeUICeremony(session, acct.ID, "")
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
@@ -352,6 +354,7 @@ func (u *UIServer) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Reque
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
var req struct {
Username string `json:"username"`
SSONonce string `json:"sso_nonce"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON")
@@ -413,7 +416,7 @@ func (u *UIServer) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Reque
return
}
nonce, err := storeUICeremony(session, accountID)
nonce, err := storeUICeremony(session, accountID, req.SSONonce)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
@@ -582,6 +585,17 @@ func (u *UIServer) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Requ
_ = u.db.ClearLoginFailures(acct.ID)
// SSO redirect flow: issue authorization code and return redirect URL as JSON.
if ceremony.ssoNonce != "" {
if callbackURL, ok := u.buildSSOCallback(r, ceremony.ssoNonce, acct.ID); ok {
u.writeAudit(r, model.EventWebAuthnLoginOK, &acct.ID, nil, "")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"redirect": callbackURL})
return
}
// SSO session expired — fall through to normal login.
}
// Issue JWT and set session cookie.
expiry := u.cfg.DefaultExpiry()
roles, err := u.db.GetRoles(acct.ID)

View File

@@ -5,7 +5,7 @@ import (
"fmt"
"time"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/mc/mcias/internal/token"
)
// validateSessionToken wraps token.ValidateToken for use by UI session middleware.

View File

@@ -27,13 +27,13 @@ import (
"sync"
"time"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/kyle/mcias/web"
"git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/vault"
"git.wntrmute.dev/mc/mcias/web"
)
const (
@@ -445,6 +445,9 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.HandleFunc("GET /unseal", u.handleUnsealPage)
uiMux.Handle("POST /unseal", unsealRateLimit(http.HandlerFunc(u.handleUnsealPost)))
// SSO authorize route (no session required, rate-limited).
uiMux.Handle("GET /sso/authorize", loginRateLimit(http.HandlerFunc(u.handleSSOAuthorize)))
// Auth routes (no session required).
uiMux.HandleFunc("GET /login", u.handleLoginPage)
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
@@ -810,6 +813,7 @@ type PageData struct {
type LoginData struct {
Error string
Username string // pre-filled on TOTP step
SSONonce string // SSO session nonce (hidden field for SSO redirect flow)
// Security (F-02): Password is no longer carried in the HTML form. Instead
// a short-lived server-side nonce is issued after successful password
// verification, and only the nonce is embedded in the TOTP step form.

View File

@@ -13,11 +13,11 @@ import (
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/vault"
)
const testIssuer = "https://auth.example.com"

View File

@@ -5,8 +5,8 @@ import (
"errors"
"fmt"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/db"
)
// DeriveFromPassphrase derives the master encryption key from a passphrase

View File

@@ -8,7 +8,7 @@ import (
"github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/mc/mcias/internal/config"
)
// NewWebAuthn creates a configured go-webauthn instance from MCIAS config.

View File

@@ -5,7 +5,7 @@ import (
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/mc/mcias/internal/config"
)
func TestNewWebAuthn(t *testing.T) {

View File

@@ -8,8 +8,8 @@ import (
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/model"
)
// DecryptCredential decrypts a stored WebAuthn credential's ID and public key

View File

@@ -7,8 +7,8 @@ import (
"github.com/go-webauthn/webauthn/protocol"
libwebauthn "github.com/go-webauthn/webauthn/webauthn"
"git.wntrmute.dev/kyle/mcias/internal/crypto"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/crypto"
"git.wntrmute.dev/mc/mcias/internal/model"
)
func testMasterKey(t *testing.T) []byte {

View File

@@ -4,7 +4,7 @@ syntax = "proto3";
package mcias.v1;
option go_package = "git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1";
option go_package = "git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1";
import "mcias/v1/common.proto";

View File

@@ -4,7 +4,7 @@ syntax = "proto3";
package mcias.v1;
option go_package = "git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1";
option go_package = "git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1";
// HealthRequest carries no parameters.
message HealthRequest {}

View File

@@ -3,7 +3,7 @@ syntax = "proto3";
package mcias.v1;
option go_package = "git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1";
option go_package = "git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1";
import "google/protobuf/timestamp.proto";

View File

@@ -3,7 +3,7 @@ syntax = "proto3";
package mcias.v1;
option go_package = "git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1";
option go_package = "git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1";
import "google/protobuf/timestamp.proto";

View File

@@ -3,7 +3,7 @@ syntax = "proto3";
package mcias.v1;
option go_package = "git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1";
option go_package = "git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1";
// PolicyRule is the wire representation of a policy rule record.
message PolicyRule {

View File

@@ -3,7 +3,7 @@ syntax = "proto3";
package mcias.v1;
option go_package = "git.wntrmute.dev/kyle/mcias/gen/mcias/v1;mciasv1";
option go_package = "git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1";
import "google/protobuf/timestamp.proto";

View File

@@ -30,13 +30,13 @@ import (
"testing"
"time"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/server"
"git.wntrmute.dev/kyle/mcias/internal/token"
"git.wntrmute.dev/kyle/mcias/internal/vault"
"git.wntrmute.dev/mc/mcias/internal/auth"
"git.wntrmute.dev/mc/mcias/internal/config"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/server"
"git.wntrmute.dev/mc/mcias/internal/token"
"git.wntrmute.dev/mc/mcias/internal/vault"
)
const e2eIssuer = "https://auth.e2e.test"

View File

@@ -0,0 +1,36 @@
// Package terminal provides secure terminal input helpers for CLI tools.
package terminal
import (
"fmt"
"os"
"golang.org/x/term"
)
// ReadPassword prints the given prompt to stderr and reads a password
// from the terminal with echo disabled. It prints a newline after the
// input is complete so the cursor advances normally.
func ReadPassword(prompt string) (string, error) {
b, err := readRaw(prompt)
if err != nil {
return "", err
}
return string(b), nil
}
// ReadPasswordBytes is like ReadPassword but returns a []byte so the
// caller can zeroize the buffer after use.
func ReadPasswordBytes(prompt string) ([]byte, error) {
return readRaw(prompt)
}
func readRaw(prompt string) ([]byte, error) {
fmt.Fprint(os.Stderr, prompt)
b, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // fd fits in int
fmt.Fprintln(os.Stderr)
if err != nil {
return nil, err
}
return b, nil
}

21
vendor/github.com/dustin/go-humanize/.travis.yml generated vendored Normal file
View File

@@ -0,0 +1,21 @@
sudo: false
language: go
go_import_path: github.com/dustin/go-humanize
go:
- 1.13.x
- 1.14.x
- 1.15.x
- 1.16.x
- stable
- master
matrix:
allow_failures:
- go: master
fast_finish: true
install:
- # Do nothing. This is needed to prevent default install action "go get -t -v ./..." from happening here (we want it to happen inside script step).
script:
- diff -u <(echo -n) <(gofmt -d -s .)
- go vet .
- go install -v -race ./...
- go test -v -race ./...

21
vendor/github.com/dustin/go-humanize/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
Copyright (c) 2005-2008 Dustin Sallings <dustin@spy.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
<http://www.opensource.org/licenses/mit-license.php>

Some files were not shown because too many files have changed in this diff Show More