diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..46dc2ee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Verify dependencies + run: go mod verify + + - name: Build + run: CGO_ENABLED=0 go build ./... + + - name: Test + run: go test ./... + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..60c235e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,125 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/kleffio/idp-authentik:${{ steps.version.outputs.version }} + ghcr.io/kleffio/idp-authentik:latest + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.KLEFF_APP_ID }} + private-key: ${{ secrets.KLEFF_APP_PRIVATE_KEY }} + owner: kleffio + repositories: plugin-registry + + - name: Open PR to plugin-registry + uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const version = "${{ steps.version.outputs.version }}"; + const branch = `update/idp-authentik-${version}`; + + // Get current plugins.json from registry repo + const { data: file } = await github.rest.repos.getContent({ + owner: "kleffio", + repo: "plugin-registry", + path: "plugins.json", + }); + + const plugins = JSON.parse(Buffer.from(file.content, "base64").toString()); + const idx = plugins.findIndex(p => p.id === "idp-authentik"); + if (idx !== -1) plugins[idx].version = version; + + // Create branch and commit + const { data: ref } = await github.rest.git.getRef({ + owner: "kleffio", + repo: "plugin-registry", + ref: "heads/main", + }); + + // Create or reset branch + try { + await github.rest.git.createRef({ + owner: "kleffio", + repo: "plugin-registry", + ref: `refs/heads/${branch}`, + sha: ref.object.sha, + }); + } catch (e) { + if (e.status === 422) { + await github.rest.git.updateRef({ + owner: "kleffio", + repo: "plugin-registry", + ref: `heads/${branch}`, + sha: ref.object.sha, + force: true, + }); + } else throw e; + } + + await github.rest.repos.createOrUpdateFileContents({ + owner: "kleffio", + repo: "plugin-registry", + path: "plugins.json", + message: `chore: bump idp-authentik to v${version}`, + content: Buffer.from(JSON.stringify(plugins, null, 2) + "\n").toString("base64"), + branch, + sha: file.sha, + }); + + const { data: pr } = await github.rest.pulls.create({ + owner: "kleffio", + repo: "plugin-registry", + title: `chore: bump idp-authentik to v${version}`, + head: branch, + base: "main", + body: `Automated release PR from \`kleffio/authentik-plugin\` tag \`v${version}\`.`, + }); + + // Auto-merge the PR + await github.rest.pulls.merge({ + owner: "kleffio", + repo: "plugin-registry", + pull_number: pr.number, + merge_method: "squash", + }); diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..93c8ac8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /build + +COPY go.mod ./ +RUN go mod download 2>/dev/null || true + +COPY . . +RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /plugin ./cmd/plugin + +# ── Runtime image ───────────────────────────────────────────────────────────── +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata + +COPY --from=builder /plugin /plugin + +ENV PLUGIN_PORT=50051 +EXPOSE 50051 +ENTRYPOINT ["/plugin"] diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go new file mode 100644 index 0000000..f985c86 --- /dev/null +++ b/cmd/plugin/main.go @@ -0,0 +1,102 @@ +// Command plugin is the entrypoint for the idp-authentik Kleff plugin. +// It wires the hexagonal layers together and starts the gRPC server. +// All Authentik setup (realm, client, admin user) is performed automatically — +// no manual configuration is required. +package main + +import ( + "context" + "log/slog" + "net" + "os" + "os/signal" + "syscall" + "time" + + pluginsv1 "github.com/kleffio/plugin-sdk-go/v1" + authentikadapter "github.com/kleffio/idp-authentik/internal/adapters/authentik" + grpcadapter "github.com/kleffio/idp-authentik/internal/adapters/grpc" + "github.com/kleffio/idp-authentik/internal/core/application" + "google.golang.org/grpc" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + // ── Infrastructure (outbound adapter) ───────────────────────────────────── + provider := authentikadapter.New(authentikadapter.Config{ + BaseURL: env("AUTHENTIK_URL", "http://authentik-server:9000"), + PublicURL: env("AUTHENTIK_PUBLIC_URL", "http://localhost:9000"), + BootstrapToken: env("AUTHENTIK_BOOTSTRAP_TOKEN", ""), + AppSlug: env("AUTHENTIK_APP_SLUG", "kleff"), + AdminEmail: env("AUTHENTIK_ADMIN_EMAIL", "admin@localhost"), + AdminPassword: env("AUTHENTIK_ADMIN_PASSWORD", "admin"), + AuthMode: "headless", + }) + + // ── Application layer ────────────────────────────────────────────────────── + svc := application.New(provider) + + // ── Inbound adapter (gRPC) ───────────────────────────────────────────────── + srv := grpcadapter.New(svc, + env("AUTHENTIK_PUBLIC_URL", "http://localhost:9000"), + env("AUTHENTIK_APP_SLUG", "kleff"), + ) + + gs := grpc.NewServer() + pluginsv1.RegisterIdentityPluginServer(gs, srv) + pluginsv1.RegisterPluginHealthServer(gs, srv) + pluginsv1.RegisterPluginUIServer(gs, srv) + + port := env("PLUGIN_PORT", "50051") + lis, err := net.Listen("tcp", ":"+port) + if err != nil { + logger.Error("listen failed", "error", err) + os.Exit(1) + } + + // Start gRPC immediately so the platform can dial while setup is in progress. + go func() { + logger.Info("plugin listening", "port", port) + if err := gs.Serve(lis); err != nil { + logger.Error("gRPC server error", "error", err) + os.Exit(1) + } + }() + + // ── Auto-configure Authentik in the background ───────────────────────────── + // Retries indefinitely — the Authentik companion containers take time to + // start, and this is safe to call multiple times (idempotent). + // srv.SetReady() is called once setup succeeds so GetOIDCConfig starts + // returning a valid config and the platform's ready check fires. + go func() { + for { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + err := provider.EnsureSetup(ctx) + cancel() + if err == nil { + logger.Info("Authentik configured", + "base", env("AUTHENTIK_URL", "http://authentik-server:9000"), + "app", env("AUTHENTIK_APP_SLUG", "kleff"), + ) + srv.SetReady() + return + } + logger.Warn("waiting for Authentik...", "error", err) + time.Sleep(5 * time.Second) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) + <-stop + logger.Info("shutting down") + gs.GracefulStop() +} + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9305294 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/kleffio/idp-authentik + +go 1.25.0 + +require ( + github.com/kleffio/plugin-sdk-go v0.1.2 + google.golang.org/grpc v1.64.0 +) + +require ( + golang.org/x/net v0.26.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d86821e --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kleffio/plugin-sdk-go v0.1.2 h1:hY27rPzcFPba+NSRVUy+Cqy3YQGCaTBIfLGBkOdh0Bw= +github.com/kleffio/plugin-sdk-go v0.1.2/go.mod h1:QSUqnkbslgBShcN7/y3vl7Q+cVo5BLoweRtbc9rMB24= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 h1:Di6ANFilr+S60a4S61ZM00vLdw0IrQOSMS2/6mrnOU0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= diff --git a/internal/adapters/authentik/client.go b/internal/adapters/authentik/client.go new file mode 100644 index 0000000..d4cd076 --- /dev/null +++ b/internal/adapters/authentik/client.go @@ -0,0 +1,1105 @@ +// Package authentik is the outbound adapter that implements ports.IDPProvider +// by talking to Authentik over its REST API and OIDC token endpoints. +package authentik + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "sync" + "time" + + "github.com/kleffio/idp-authentik/internal/core/domain" +) + +// Config holds Authentik connection parameters loaded from env vars. +type Config struct { + BaseURL string // internal URL, e.g. "http://authentik-server:9000" + PublicURL string // browser-reachable URL, e.g. "http://localhost:9000" + BootstrapToken string // AUTHENTIK_BOOTSTRAP_TOKEN — used to call the REST API + AppSlug string // application slug in Authentik, default "kleff" + AdminEmail string // email of the initial Kleff admin user + AdminPassword string // password of the initial Kleff admin user + AuthMode string // "headless" (default) or "redirect" +} + +// cachedEndpoints are discovered after EnsureSetup completes. +type cachedEndpoints struct { + tokenEndpoint string + jwksURI string + issuerURL string + clientID string +} + +// Client implements ports.IDPProvider for Authentik. +type Client struct { + cfg Config + http *http.Client + + mu sync.RWMutex + ep cachedEndpoints + epReady bool +} + +// New creates a Client. Call EnsureSetup before performing auth operations. +func New(cfg Config) *Client { + if cfg.AppSlug == "" { + cfg.AppSlug = "kleff" + } + if cfg.AuthMode == "" { + cfg.AuthMode = "headless" + } + return &Client{ + cfg: cfg, + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +// ── EnsureSetup ─────────────────────────────────────────────────────────────── + +// EnsureSetup waits for Authentik to be reachable, then idempotently creates +// the kleff OAuth2 provider + application and caches the OIDC endpoints. +func (c *Client) EnsureSetup(ctx context.Context) error { + base := strings.TrimRight(c.cfg.BaseURL, "/") + tok := c.cfg.BootstrapToken + + // 1. Wait until the Authentik API is up. + if err := c.waitReady(ctx, base, tok); err != nil { + return err + } + + // 2. Find the flow PKs we need. + authFlowPK, err := c.findFlowPK(ctx, base, tok, "default-authentication-flow") + if err != nil { + return fmt.Errorf("find auth flow: %w", err) + } + authzFlowPK, err := c.findFlowPK(ctx, base, tok, "default-provider-authorization-implicit-consent") + if err != nil { + return fmt.Errorf("find authz flow: %w", err) + } + invalidationFlowPK, err := c.findFlowPK(ctx, base, tok, "default-provider-invalidation-flow") + if err != nil { + return fmt.Errorf("find invalidation flow: %w", err) + } + + // 3. Find scope property mapping PKs (openid, email, profile). + scopePKs, err := c.findScopePKs(ctx, base, tok) + if err != nil { + return fmt.Errorf("find scope mappings: %w", err) + } + + // 4. Find an RSA signing certificate (needed for RS256 access-token signing). + certPK, err := c.findSigningCert(ctx, base, tok) + if err != nil { + return fmt.Errorf("find signing cert: %w", err) + } + + // 5. Create or update the OAuth2 provider, always ensuring RS256 signing. + providerPK, err := c.ensureProvider(ctx, base, tok, authFlowPK, authzFlowPK, invalidationFlowPK, scopePKs, certPK) + if err != nil { + return fmt.Errorf("ensure provider: %w", err) + } + + // 6. Create or update the application. + if err := c.ensureApplication(ctx, base, tok, providerPK, authzFlowPK); err != nil { + return fmt.Errorf("ensure application: %w", err) + } + + // 7. Create a headless auth flow (no MFA) used by the headless Login() path. + // Authentik 2025.2 removed real-password ROPC support; the flow executor is the + // correct way to authenticate headlessly. + if err := c.ensureHeadlessFlow(ctx, base, tok); err != nil { + return fmt.Errorf("ensure headless flow: %w", err) + } + + // 8. Fetch OIDC discovery document to get the canonical endpoints. + if err := c.discoverEndpoints(ctx, base); err != nil { + return fmt.Errorf("discover endpoints: %w", err) + } + + // 9. Seed the admin user and "admin" group. + if err := c.EnsureAdmin(ctx); err != nil { + // Non-fatal — log and continue. + fmt.Printf("authentik: warning: EnsureAdmin: %v\n", err) + } + + return nil +} + +func (c *Client) waitReady(ctx context.Context, base, tok string) error { + for { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/v3/root/config/", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := c.http.Do(req) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + select { + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for Authentik") + case <-time.After(3 * time.Second): + } + } +} + +func (c *Client) findFlowPK(ctx context.Context, base, tok, slug string) (string, error) { + u := base + "/api/v3/flows/instances/?slug=" + url.QueryEscape(slug) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + var page struct { + Results []struct { + PK string `json:"pk"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + return "", err + } + if len(page.Results) == 0 { + return "", fmt.Errorf("flow %q not found", slug) + } + return page.Results[0].PK, nil +} + +func (c *Client) findScopePKs(ctx context.Context, base, tok string) ([]string, error) { + managed := []string{ + "goauthentik.io/providers/oauth2/scope-openid", + "goauthentik.io/providers/oauth2/scope-email", + "goauthentik.io/providers/oauth2/scope-profile", + } + var pks []string + for _, m := range managed { + u := base + "/api/v3/propertymappings/provider/scope/?managed=" + url.QueryEscape(m) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + var page struct { + Results []struct { + PK string `json:"pk"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + resp.Body.Close() + return nil, err + } + resp.Body.Close() + if len(page.Results) > 0 { + pks = append(pks, page.Results[0].PK) + } + } + return pks, nil +} + +func (c *Client) findSigningCert(ctx context.Context, base, tok string) (string, error) { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, + base+"/api/v3/crypto/certificatekeypairs/?has_key=true", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + var page struct { + Results []struct { + PK string `json:"pk"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + resp.Body.Close() + return "", err + } + resp.Body.Close() + if len(page.Results) == 0 { + return "", fmt.Errorf("no signing certificate found in Authentik") + } + return page.Results[0].PK, nil +} + +func (c *Client) ensureProvider(ctx context.Context, base, tok, authFlowPK, authzFlowPK, invalidationFlowPK string, scopePKs []string, certPK string) (int, error) { + // Check if provider already exists. + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, + base+"/api/v3/providers/oauth2/?name=kleff", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := c.http.Do(req) + if err != nil { + return 0, err + } + var page struct { + Results []struct { + PK int `json:"pk"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + resp.Body.Close() + return 0, err + } + resp.Body.Close() + + providerPayload := map[string]any{ + "name": "kleff", + "client_id": "kleff-panel", + "client_type": "public", + "authorization_flow": authzFlowPK, + "authentication_flow": authFlowPK, + "invalidation_flow": invalidationFlowPK, + "sub_mode": "user_email", + "include_claims_in_id_token": true, + "property_mappings": scopePKs, + "access_token_validity": "hours=1", + "refresh_token_validity": "days=30", + "redirect_uris": []map[string]string{ + {"matching_mode": "regex", "url": ".*"}, + }, + "signing_key": certPK, + "jwt_alg": "RS256", + } + + if len(page.Results) > 0 { + // Provider exists — PATCH to ensure RS256 signing is set. + pk := page.Results[0].PK + patchPayload, _ := json.Marshal(map[string]any{ + "signing_key": certPK, + "jwt_alg": "RS256", + }) + patchReq, _ := http.NewRequestWithContext(ctx, http.MethodPatch, + fmt.Sprintf("%s/api/v3/providers/oauth2/%d/", base, pk), + strings.NewReader(string(patchPayload))) + patchReq.Header.Set("Authorization", "Bearer "+tok) + patchReq.Header.Set("Content-Type", "application/json") + patchResp, err := c.http.Do(patchReq) + if err != nil { + return 0, fmt.Errorf("patch provider RS256: %w", err) + } + b, _ := io.ReadAll(patchResp.Body) + patchResp.Body.Close() + if patchResp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("patch provider RS256: status %d: %s", patchResp.StatusCode, string(b)) + } + return pk, nil + } + + // Create the provider. + payload, _ := json.Marshal(providerPayload) + req, _ = http.NewRequestWithContext(ctx, http.MethodPost, + base+"/api/v3/providers/oauth2/", + strings.NewReader(string(payload))) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + resp, err = c.http.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp.Body) + return 0, fmt.Errorf("create provider: status %d: %s", resp.StatusCode, string(b)) + } + var created struct { + PK int `json:"pk"` + } + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return 0, err + } + return created.PK, nil +} + +func (c *Client) ensureApplication(ctx context.Context, base, tok string, providerPK int, authzFlowPK string) error { + // Check if application already exists. + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, + base+"/api/v3/core/applications/?slug="+c.cfg.AppSlug, nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := c.http.Do(req) + if err != nil { + return err + } + var page struct { + Results []struct { + Slug string `json:"slug"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + resp.Body.Close() + return err + } + resp.Body.Close() + if len(page.Results) > 0 { + return nil // already exists + } + + // Create the application. + payload, _ := json.Marshal(map[string]any{ + "name": "Kleff", + "slug": c.cfg.AppSlug, + "provider": providerPK, + "open_in_new_tab": false, + "policy_engine_mode": "any", + }) + req, _ = http.NewRequestWithContext(ctx, http.MethodPost, + base+"/api/v3/core/applications/", + strings.NewReader(string(payload))) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + resp, err = c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("create application: status %d: %s", resp.StatusCode, string(b)) + } + return nil +} + +func (c *Client) discoverEndpoints(ctx context.Context, base string) error { + issuerURL := fmt.Sprintf("%s/application/o/%s/", base, c.cfg.AppSlug) + discoveryURL := issuerURL + ".well-known/openid-configuration" + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("OIDC discovery: status %d", resp.StatusCode) + } + var doc struct { + Issuer string `json:"issuer"` + TokenEndpoint string `json:"token_endpoint"` + JwksURI string `json:"jwks_uri"` + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return err + } + + // Build public-facing issuer URL (browser-reachable). + publicBase := strings.TrimRight(c.cfg.PublicURL, "/") + if publicBase == "" { + publicBase = base + } + publicIssuer := fmt.Sprintf("%s/application/o/%s/", publicBase, c.cfg.AppSlug) + publicJwks := fmt.Sprintf("%s/application/o/%s/jwks/", publicBase, c.cfg.AppSlug) + + c.mu.Lock() + c.ep = cachedEndpoints{ + tokenEndpoint: doc.TokenEndpoint, + jwksURI: publicJwks, + issuerURL: publicIssuer, + clientID: "kleff-panel", + } + c.epReady = true + c.mu.Unlock() + return nil +} + +// ── EnsureAdmin ─────────────────────────────────────────────────────────────── + +// EnsureAdmin finds or creates the "admin" group in Authentik, then finds or +// creates the bootstrap admin user and adds them to that group. +func (c *Client) EnsureAdmin(ctx context.Context) error { + base := strings.TrimRight(c.cfg.BaseURL, "/") + tok := c.cfg.BootstrapToken + + // 1. Find or create the "admin" group (used for Kleff role mapping). + groupPK, err := c.ensureGroup(ctx, base, tok, "admin") + if err != nil { + return fmt.Errorf("ensure admin group: %w", err) + } + + // 2. Find the bootstrap admin user by email. + userPK, err := c.findOrCreateUser(ctx, base, tok, c.cfg.AdminEmail, c.cfg.AdminPassword) + if err != nil { + return fmt.Errorf("find/create admin user: %w", err) + } + + // 3. Add the user to the "admin" group (idempotent). + return c.addUserToGroup(ctx, base, tok, groupPK, userPK) +} + +func (c *Client) ensureGroup(ctx context.Context, base, tok, name string) (string, error) { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, + base+"/api/v3/core/groups/?name="+url.QueryEscape(name), nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + var page struct { + Results []struct { + PK string `json:"pk"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + resp.Body.Close() + return "", err + } + resp.Body.Close() + if len(page.Results) > 0 { + return page.Results[0].PK, nil + } + + // Create the group. + payload, _ := json.Marshal(map[string]any{"name": name, "is_superuser": false}) + req, _ = http.NewRequestWithContext(ctx, http.MethodPost, + base+"/api/v3/core/groups/", + strings.NewReader(string(payload))) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + resp, err = c.http.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("create group %q: status %d: %s", name, resp.StatusCode, string(b)) + } + var created struct { + PK string `json:"pk"` + } + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return "", err + } + return created.PK, nil +} + +func (c *Client) findOrCreateUser(ctx context.Context, base, tok, email, password string) (int, error) { + // Search by email (username in Authentik). + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, + base+"/api/v3/core/users/?search="+url.QueryEscape(email), nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := c.http.Do(req) + if err != nil { + return 0, err + } + var page struct { + Results []struct { + PK int `json:"pk"` + Email string `json:"email"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + resp.Body.Close() + return 0, err + } + resp.Body.Close() + for _, u := range page.Results { + if u.Email == email { + return u.PK, nil + } + } + + // Create the user. + username := strings.Split(email, "@")[0] + payload, _ := json.Marshal(map[string]any{ + "username": username, + "email": email, + "name": "Admin", + "is_active": true, + "type": "internal", + "attributes": map[string]any{}, + }) + req, _ = http.NewRequestWithContext(ctx, http.MethodPost, + base+"/api/v3/core/users/", + strings.NewReader(string(payload))) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + resp, err = c.http.Do(req) + if err != nil { + return 0, err + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + return 0, fmt.Errorf("create user: status %d: %s", resp.StatusCode, string(body)) + } + var created struct { + PK int `json:"pk"` + } + if err := json.Unmarshal(body, &created); err != nil { + return 0, err + } + + // Set the password. + pwPayload, _ := json.Marshal(map[string]any{"password": password}) + req, _ = http.NewRequestWithContext(ctx, http.MethodPost, + fmt.Sprintf("%s/api/v3/core/users/%d/set_password/", base, created.PK), + strings.NewReader(string(pwPayload))) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + resp, err = c.http.Do(req) + if err != nil { + return 0, err + } + resp.Body.Close() + + return created.PK, nil +} + +func (c *Client) addUserToGroup(ctx context.Context, base, tok, groupPK string, userPK int) error { + payload, _ := json.Marshal(map[string]any{"pk": userPK}) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, + fmt.Sprintf("%s/api/v3/core/groups/%s/add_user/", base, groupPK), + strings.NewReader(string(payload))) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return err + } + resp.Body.Close() + // 204 = success, 400 = already member — both are fine + return nil +} + +// ── Headless flow setup ─────────────────────────────────────────────────────── + +// ensureHeadlessFlow idempotently creates a custom Authentik authentication flow +// for headless login. The flow has three stages: +// +// IdentificationStage (order 10) → PasswordStage (order 20) → UserLoginStage (order 30) +// +// No MFA stage is included — Authentik 2025.2 removed real-password ROPC support +// and the flow executor is now the correct headless path. +func (c *Client) ensureHeadlessFlow(ctx context.Context, base, tok string) error { + // Find or create the flow. + flowPK, _ := c.findFlowPK(ctx, base, tok, "kleff-headless-auth") + if flowPK == "" { + var err error + flowPK, err = c.createFlow(ctx, base, tok, "kleff-headless-auth", + "Kleff Headless Authentication", "authentication") + if err != nil { + return fmt.Errorf("create flow: %w", err) + } + } + + // Find or create stages (idempotent). + idPK, err := c.findOrCreateStage(ctx, base, tok, "identification", + "kleff-identification", map[string]any{ + "user_fields": []string{"username", "email"}, + "show_matched_user": true, + }) + if err != nil { + return fmt.Errorf("create identification stage: %w", err) + } + + pwPK, err := c.findOrCreateStage(ctx, base, tok, "password", + "kleff-password", map[string]any{ + "backends": []string{"authentik.core.auth.InbuiltBackend"}, + }) + if err != nil { + return fmt.Errorf("create password stage: %w", err) + } + + loginPK, err := c.findOrCreateStage(ctx, base, tok, "user_login", + "kleff-user-login", map[string]any{}) + if err != nil { + return fmt.Errorf("create user_login stage: %w", err) + } + + // Ensure all bindings exist — always idempotent (duplicate bindings are rejected by Authentik). + stages := []struct { + pk string + order int + }{ + {idPK, 10}, + {pwPK, 20}, + {loginPK, 30}, + } + for _, s := range stages { + if err := c.bindStageToFlow(ctx, base, tok, flowPK, s.pk, s.order); err != nil { + return fmt.Errorf("bind stage (order %d): %w", s.order, err) + } + } + return nil +} + +func (c *Client) createFlow(ctx context.Context, base, tok, slug, name, designation string) (string, error) { + payload, _ := json.Marshal(map[string]any{ + "name": name, + "slug": slug, + "designation": designation, + "title": name, + }) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, + base+"/api/v3/flows/instances/", strings.NewReader(string(payload))) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) + } + var created struct { + PK string `json:"pk"` + } + if err := json.Unmarshal(body, &created); err != nil { + return "", err + } + return created.PK, nil +} + +// findOrCreateStage looks up a stage by name and creates it if absent. +// stageType must be one of: "identification", "password", "user_login". +func (c *Client) findOrCreateStage(ctx context.Context, base, tok, stageType, name string, extra map[string]any) (string, error) { + listURL := fmt.Sprintf("%s/api/v3/stages/%s/?name=%s", base, stageType, url.QueryEscape(name)) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + var page struct { + Results []struct { + PK string `json:"pk"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + resp.Body.Close() + return "", err + } + resp.Body.Close() + if len(page.Results) > 0 { + return page.Results[0].PK, nil + } + + // Create. + payload := map[string]any{"name": name} + for k, v := range extra { + payload[k] = v + } + body, _ := json.Marshal(payload) + req, _ = http.NewRequestWithContext(ctx, http.MethodPost, + fmt.Sprintf("%s/api/v3/stages/%s/", base, stageType), + strings.NewReader(string(body))) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + resp, err = c.http.Do(req) + if err != nil { + return "", err + } + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("create %s stage: status %d: %s", stageType, resp.StatusCode, string(respBody)) + } + var created struct { + PK string `json:"pk"` + } + if err := json.Unmarshal(respBody, &created); err != nil { + return "", err + } + return created.PK, nil +} + +func (c *Client) bindStageToFlow(ctx context.Context, base, tok, flowPK, stagePK string, order int) error { + payload, _ := json.Marshal(map[string]any{ + "target": flowPK, // Authentik uses "target" for the flow PK in FlowStageBinding + "stage": stagePK, + "order": order, + "enabled": true, + }) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, + base+"/api/v3/flows/bindings/", strings.NewReader(string(payload))) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return err + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + // 201 = created; 400 with "already exists"-style error = already bound (idempotent). + if resp.StatusCode == http.StatusCreated { + return nil + } + // Treat any 400 whose body mentions the binding already existing as success. + if resp.StatusCode == http.StatusBadRequest && + (strings.Contains(string(body), "already") || strings.Contains(string(body), "unique")) { + return nil + } + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) + } + return nil +} + +// ── Auth operations ─────────────────────────────────────────────────────────── + +// Login authenticates using Authentik's flow executor API + OAuth2 authorization +// code exchange with PKCE. This replaces ROPC which was removed in Authentik 2025.2. +// +// Flow: +// 1. Drive the `kleff-headless-auth` flow (Identification → Password → UserLogin) +// via the flow executor, carrying a session cookie jar. +// 2. Use the authenticated session to get an authorization code from the OIDC +// authorization endpoint (with implicit consent). +// 3. Exchange the code for tokens at the token endpoint. +func (c *Client) Login(ctx context.Context, username, password string) (*domain.TokenSet, error) { + base := strings.TrimRight(c.cfg.BaseURL, "/") + + // Per-request HTTP client: shared cookie jar. + // Follow redirects within the flow executor (each POST → 302 → GET → JSON), + // but stop at the authorization endpoint redirect so we can capture the code. + jar, _ := cookiejar.New(nil) + hc := &http.Client{ + Timeout: 15 * time.Second, + Jar: jar, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + // Follow redirects inside the flow executor, preserving Accept header. + if strings.Contains(req.URL.Path, "/api/v3/flows/executor/") { + req.Header.Set("Accept", "application/json") + return nil + } + // Stop all other redirects (e.g. authorization endpoint → callback with code). + return http.ErrUseLastResponse + }, + } + + flowURL := base + "/api/v3/flows/executor/kleff-headless-auth/" + + // ── Step 1: Start flow ──────────────────────────────────────────────────── + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, flowURL+"?next=%2F", nil) + req.Header.Set("Accept", "application/json") + resp, err := hc.Do(req) + if err != nil { + return nil, fmt.Errorf("authentik login: start flow: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var stage struct { + Component string `json:"component"` + } + if err := json.Unmarshal(body, &stage); err != nil { + return nil, fmt.Errorf("authentik login: parse initial challenge: %w", err) + } + if stage.Component != "ak-stage-identification" { + return nil, fmt.Errorf("authentik login: unexpected initial stage %q", stage.Component) + } + + // ── Step 2: Submit username ─────────────────────────────────────────────── + uidBody, _ := json.Marshal(map[string]any{"uid_field": username}) + req, _ = http.NewRequestWithContext(ctx, http.MethodPost, flowURL, + strings.NewReader(string(uidBody))) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + resp, err = hc.Do(req) + if err != nil { + return nil, fmt.Errorf("authentik login: submit username: %w", err) + } + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + + if err := json.Unmarshal(body, &stage); err != nil { + return nil, fmt.Errorf("authentik login: parse password stage: %w", err) + } + // If we got back identification stage again, the user doesn't exist. + if stage.Component != "ak-stage-password" { + return nil, &domain.ErrUnauthorized{Msg: "invalid username or password"} + } + + // ── Step 3: Submit password ─────────────────────────────────────────────── + pwBody, _ := json.Marshal(map[string]any{"password": password}) + req, _ = http.NewRequestWithContext(ctx, http.MethodPost, flowURL, + strings.NewReader(string(pwBody))) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + resp, err = hc.Do(req) + if err != nil { + return nil, fmt.Errorf("authentik login: submit password: %w", err) + } + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + + var flowResp struct { + Component string `json:"component"` + To string `json:"to"` + } + if err := json.Unmarshal(body, &flowResp); err != nil { + return nil, fmt.Errorf("authentik login: parse flow response: %w", err) + } + // Back on the password stage → wrong password. + if flowResp.Component == "ak-stage-password" { + return nil, &domain.ErrUnauthorized{Msg: "invalid username or password"} + } + if flowResp.Component != "xak-flow-redirect" { + return nil, fmt.Errorf("authentik login: unexpected stage after password: %q", flowResp.Component) + } + // Session cookie is now set in jar — the user is logged in. + + // ── Step 4: Authorization code request (PKCE) ───────────────────────────── + codeVerifier := generateCodeVerifier() + codeChallenge := computeCodeChallenge(codeVerifier) + state := randomHex(8) + nonce := randomHex(8) + redirectURI := "http://localhost/callback" + + authURL := fmt.Sprintf( + "%s/application/o/authorize/?client_id=%s&response_type=code&scope=%s&redirect_uri=%s&state=%s&nonce=%s&code_challenge=%s&code_challenge_method=S256", + base, + url.QueryEscape("kleff-panel"), + url.QueryEscape("openid profile email"), + url.QueryEscape(redirectURI), + state, nonce, + codeChallenge, + ) + req, _ = http.NewRequestWithContext(ctx, http.MethodGet, authURL, nil) + resp, err = hc.Do(req) + if err != nil { + return nil, fmt.Errorf("authentik login: authorization request: %w", err) + } + resp.Body.Close() + + if resp.StatusCode != http.StatusFound { + return nil, fmt.Errorf("authentik login: expected auth redirect, got status %d", resp.StatusCode) + } + location := resp.Header.Get("Location") + locURL, err := url.Parse(location) + if err != nil { + return nil, fmt.Errorf("authentik login: parse redirect URL: %w", err) + } + code := locURL.Query().Get("code") + if code == "" { + return nil, fmt.Errorf("authentik login: no auth code in redirect: %s", location) + } + + // ── Step 5: Token exchange ──────────────────────────────────────────────── + c.mu.RLock() + ep := c.ep + c.mu.RUnlock() + + data := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {ep.clientID}, + "code": {code}, + "redirect_uri": {redirectURI}, + "code_verifier": {codeVerifier}, + } + req, err = http.NewRequestWithContext(ctx, http.MethodPost, ep.tokenEndpoint, + strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("authentik login: token exchange: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err = c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("authentik login: token exchange: %w", err) + } + defer resp.Body.Close() + body, _ = io.ReadAll(resp.Body) + + var tok struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := json.Unmarshal(body, &tok); err != nil { + return nil, fmt.Errorf("authentik login: parse token response: %w", err) + } + if tok.Error != "" { + if tok.Error == "invalid_grant" { + return nil, &domain.ErrUnauthorized{Msg: "invalid username or password"} + } + return nil, fmt.Errorf("authentik login: token error: %s: %s", tok.Error, tok.ErrorDescription) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("authentik login: unexpected token status %d: %s", resp.StatusCode, string(body)) + } + return &domain.TokenSet{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + IDToken: tok.IDToken, + TokenType: tok.TokenType, + ExpiresIn: tok.ExpiresIn, + Scope: tok.Scope, + }, nil +} + +// ── PKCE helpers ────────────────────────────────────────────────────────────── + +func generateCodeVerifier() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func computeCodeChallenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return fmt.Sprintf("%x", b) +} + +// Register creates a new user in Authentik via the REST API. +func (c *Client) Register(ctx context.Context, req domain.RegisterRequest) (string, error) { + base := strings.TrimRight(c.cfg.BaseURL, "/") + tok := c.cfg.BootstrapToken + + payload, _ := json.Marshal(map[string]any{ + "username": req.Username, + "email": req.Email, + "name": strings.TrimSpace(req.FirstName + " " + req.LastName), + "is_active": true, + "type": "internal", + "attributes": map[string]any{}, + }) + httpReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, + base+"/api/v3/core/users/", + strings.NewReader(string(payload))) + httpReq.Header.Set("Authorization", "Bearer "+tok) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(httpReq) + if err != nil { + return "", fmt.Errorf("authentik register: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode == http.StatusBadRequest { + // Authentik returns 400 with detail if the username/email is taken. + return "", &domain.ErrConflict{Msg: "user already exists"} + } + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("authentik register: status %d: %s", resp.StatusCode, string(body)) + } + + var created struct { + PK int `json:"pk"` + } + if err := json.Unmarshal(body, &created); err != nil { + return "", err + } + + // Set the password. + pwPayload, _ := json.Marshal(map[string]any{"password": req.Password}) + pwReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, + fmt.Sprintf("%s/api/v3/core/users/%d/set_password/", base, created.PK), + strings.NewReader(string(pwPayload))) + pwReq.Header.Set("Authorization", "Bearer "+tok) + pwReq.Header.Set("Content-Type", "application/json") + pwResp, err := c.http.Do(pwReq) + if err != nil { + return "", fmt.Errorf("authentik register: set password: %w", err) + } + pwResp.Body.Close() + + return fmt.Sprintf("%d", created.PK), nil +} + +// RefreshToken exchanges a refresh token for a new token set. +func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*domain.TokenSet, error) { + c.mu.RLock() + ep := c.ep + c.mu.RUnlock() + + data := url.Values{ + "grant_type": {"refresh_token"}, + "client_id": {ep.clientID}, + "refresh_token": {refreshToken}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ep.tokenEndpoint, + strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("authentik refresh: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("authentik refresh: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var tok struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := json.Unmarshal(body, &tok); err != nil { + return nil, fmt.Errorf("authentik refresh: parse response: %w", err) + } + if tok.Error != "" { + if tok.Error == "invalid_grant" { + return nil, &domain.ErrUnauthorized{Msg: "refresh token is invalid or expired"} + } + return nil, fmt.Errorf("authentik refresh: %s: %s", tok.Error, tok.ErrorDescription) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("authentik refresh: unexpected status %d", resp.StatusCode) + } + return &domain.TokenSet{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + IDToken: tok.IDToken, + TokenType: tok.TokenType, + ExpiresIn: tok.ExpiresIn, + Scope: tok.Scope, + }, nil +} + +// OIDCConfig returns the discovery parameters the frontend needs. +func (c *Client) OIDCConfig() domain.OIDCConfig { + c.mu.RLock() + ep := c.ep + c.mu.RUnlock() + authMode := c.cfg.AuthMode + if authMode == "" { + authMode = "headless" + } + return domain.OIDCConfig{ + Authority: ep.issuerURL, + ClientID: ep.clientID, + JwksURI: ep.jwksURI, + AuthMode: authMode, + } +} + +// jwksURI returns the internal JWKS URI for token validation. +func (c *Client) jwksURI() string { + base := strings.TrimRight(c.cfg.BaseURL, "/") + return fmt.Sprintf("%s/application/o/%s/jwks/", base, c.cfg.AppSlug) +} diff --git a/internal/adapters/authentik/jwks.go b/internal/adapters/authentik/jwks.go new file mode 100644 index 0000000..ba5e2e9 --- /dev/null +++ b/internal/adapters/authentik/jwks.go @@ -0,0 +1,203 @@ +package authentik + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "strings" + "sync" + "time" + + "github.com/kleffio/idp-authentik/internal/core/domain" +) + +var ( + jwksMu sync.RWMutex + jwksKeys = map[string]crypto.PublicKey{} + jwksTTL time.Time +) + +const jwksCacheDuration = 5 * time.Minute + +// ValidateToken verifies a JWT (RS256 or ES256) against Authentik's JWKS endpoint. +// Keys are cached for 5 minutes; a cache miss triggers one re-fetch. +func (c *Client) ValidateToken(ctx context.Context, rawToken string) (*domain.TokenClaims, error) { + parts := strings.Split(rawToken, ".") + if len(parts) != 3 { + return nil, &domain.ErrUnauthorized{Msg: "malformed JWT"} + } + + var header struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + } + if err := decodeSegment(parts[0], &header); err != nil { + return nil, &domain.ErrUnauthorized{Msg: "invalid JWT header"} + } + + key, err := c.getKey(ctx, header.Kid) + if err != nil { + return nil, &domain.ErrUnauthorized{Msg: err.Error()} + } + + sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, &domain.ErrUnauthorized{Msg: "invalid JWT signature encoding"} + } + + message := parts[0] + "." + parts[1] + digest := sha256.Sum256([]byte(message)) + + switch header.Alg { + case "RS256": + rsaKey, ok := key.(*rsa.PublicKey) + if !ok { + return nil, &domain.ErrUnauthorized{Msg: "key type mismatch for RS256"} + } + if err := rsa.VerifyPKCS1v15(rsaKey, crypto.SHA256, digest[:], sigBytes); err != nil { + return nil, &domain.ErrUnauthorized{Msg: "invalid JWT signature"} + } + case "ES256": + ecKey, ok := key.(*ecdsa.PublicKey) + if !ok { + return nil, &domain.ErrUnauthorized{Msg: "key type mismatch for ES256"} + } + if len(sigBytes) != 64 { + return nil, &domain.ErrUnauthorized{Msg: "invalid ES256 signature length"} + } + r := new(big.Int).SetBytes(sigBytes[:32]) + s := new(big.Int).SetBytes(sigBytes[32:]) + if !ecdsa.Verify(ecKey, digest[:], r, s) { + return nil, &domain.ErrUnauthorized{Msg: "invalid JWT signature"} + } + default: + return nil, &domain.ErrUnauthorized{Msg: fmt.Sprintf("unsupported algorithm %q", header.Alg)} + } + + var claims struct { + Sub string `json:"sub"` + Email string `json:"email"` + Exp int64 `json:"exp"` + Roles []string `json:"roles"` + Groups []string `json:"groups"` // Authentik puts groups in the "groups" claim + } + if err := decodeSegment(parts[1], &claims); err != nil { + return nil, &domain.ErrUnauthorized{Msg: "invalid JWT claims"} + } + if claims.Sub == "" { + return nil, &domain.ErrUnauthorized{Msg: "missing sub claim"} + } + if claims.Exp > 0 && time.Now().Unix() > claims.Exp { + return nil, &domain.ErrUnauthorized{Msg: "token expired"} + } + + roles := append(claims.Roles, claims.Groups...) + return &domain.TokenClaims{Subject: claims.Sub, Email: claims.Email, Roles: roles}, nil +} + +func (c *Client) getKey(ctx context.Context, kid string) (crypto.PublicKey, error) { + jwksMu.RLock() + key, ok := jwksKeys[kid] + fresh := time.Now().Before(jwksTTL) + jwksMu.RUnlock() + + if ok && fresh { + return key, nil + } + if err := c.fetchJWKS(ctx); err != nil { + return nil, fmt.Errorf("fetch JWKS: %w", err) + } + jwksMu.RLock() + key, ok = jwksKeys[kid] + jwksMu.RUnlock() + if !ok { + return nil, fmt.Errorf("unknown key id %q", kid) + } + return key, nil +} + +func (c *Client) fetchJWKS(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.jwksURI(), nil) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + var set struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Use string `json:"use"` + Crv string `json:"crv"` + N string `json:"n"` + E string `json:"e"` + X string `json:"x"` + Y string `json:"y"` + } `json:"keys"` + } + if err := json.NewDecoder(resp.Body).Decode(&set); err != nil { + return err + } + + jwksMu.Lock() + defer jwksMu.Unlock() + for _, k := range set.Keys { + if k.Use != "sig" && k.Use != "" { + continue + } + switch k.Kty { + case "RSA": + nBytes, _ := base64.RawURLEncoding.DecodeString(k.N) + eBytes, _ := base64.RawURLEncoding.DecodeString(k.E) + if len(nBytes) == 0 || len(eBytes) == 0 { + continue + } + jwksKeys[k.Kid] = &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: int(new(big.Int).SetBytes(eBytes).Int64()), + } + case "EC": + xBytes, _ := base64.RawURLEncoding.DecodeString(k.X) + yBytes, _ := base64.RawURLEncoding.DecodeString(k.Y) + if len(xBytes) == 0 || len(yBytes) == 0 { + continue + } + var curve elliptic.Curve + switch k.Crv { + case "P-256": + curve = elliptic.P256() + case "P-384": + curve = elliptic.P384() + default: + continue + } + jwksKeys[k.Kid] = &ecdsa.PublicKey{ + Curve: curve, + X: new(big.Int).SetBytes(xBytes), + Y: new(big.Int).SetBytes(yBytes), + } + } + } + jwksTTL = time.Now().Add(jwksCacheDuration) + return nil +} + +func decodeSegment(seg string, v any) error { + b, err := base64.RawURLEncoding.DecodeString(seg) + if err != nil { + return err + } + return json.Unmarshal(b, v) +} diff --git a/internal/adapters/grpc/server.go b/internal/adapters/grpc/server.go new file mode 100644 index 0000000..595cac3 --- /dev/null +++ b/internal/adapters/grpc/server.go @@ -0,0 +1,174 @@ +// Package grpc is the inbound gRPC adapter for the idp-authentik plugin. +package grpc + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + + pluginsv1 "github.com/kleffio/plugin-sdk-go/v1" + "github.com/kleffio/idp-authentik/internal/core/application" + "github.com/kleffio/idp-authentik/internal/core/domain" +) + +// Server implements all pluginsv1 server interfaces declared by this plugin. +type Server struct { + pluginsv1.UnimplementedIdentityPluginServer + pluginsv1.UnimplementedPluginHealthServer + pluginsv1.UnimplementedPluginUIServer + svc *application.Service + publicURL string // browser-reachable Authentik URL (for UI manifest) + appSlug string + setupDone atomic.Bool // true once EnsureSetup has completed successfully +} + +// New creates a Server backed by the given application Service. +func New(svc *application.Service, publicURL, appSlug string) *Server { + if appSlug == "" { + appSlug = "kleff" + } + return &Server{svc: svc, publicURL: publicURL, appSlug: appSlug} +} + +// SetReady marks setup as complete. Called by main after EnsureSetup succeeds. +func (s *Server) SetReady() { s.setupDone.Store(true) } + +// ── PluginHealth ────────────────────────────────────────────────────────────── + +func (s *Server) Health(_ context.Context, _ *pluginsv1.HealthRequest) (*pluginsv1.HealthResponse, error) { + return &pluginsv1.HealthResponse{ + Status: pluginsv1.HealthStatusHealthy, + Message: "Authentik plugin running", + }, nil +} + +func (s *Server) GetCapabilities(_ context.Context, _ *pluginsv1.GetCapabilitiesRequest) (*pluginsv1.GetCapabilitiesResponse, error) { + return &pluginsv1.GetCapabilitiesResponse{ + Capabilities: []string{ + pluginsv1.CapabilityIdentityProvider, + pluginsv1.CapabilityUIManifest, + }, + }, nil +} + +// ── IdentityPlugin ──────────────────────────────────────────────────────────── + +func (s *Server) Login(ctx context.Context, req *pluginsv1.LoginRequest) (*pluginsv1.LoginResponse, error) { + tok, err := s.svc.Login(ctx, req.Username, req.Password) + if err != nil { + return &pluginsv1.LoginResponse{Error: toPluginError(err)}, nil + } + return &pluginsv1.LoginResponse{Token: toTokenSet(tok)}, nil +} + +func (s *Server) Register(ctx context.Context, req *pluginsv1.RegisterRequest) (*pluginsv1.RegisterResponse, error) { + userID, err := s.svc.Register(ctx, domain.RegisterRequest{ + Username: req.Username, + Email: req.Email, + Password: req.Password, + FirstName: req.FirstName, + LastName: req.LastName, + }) + if err != nil { + return &pluginsv1.RegisterResponse{Error: toPluginError(err)}, nil + } + return &pluginsv1.RegisterResponse{UserID: userID}, nil +} + +func (s *Server) GetUser(_ context.Context, _ *pluginsv1.GetUserRequest) (*pluginsv1.GetUserResponse, error) { + return &pluginsv1.GetUserResponse{ + Error: &pluginsv1.PluginError{ + Code: pluginsv1.ErrorCodeNotSupported, + Message: "GetUser is not supported; use token claims instead", + }, + }, nil +} + +func (s *Server) ValidateToken(ctx context.Context, req *pluginsv1.ValidateTokenRequest) (*pluginsv1.ValidateTokenResponse, error) { + claims, err := s.svc.ValidateToken(ctx, req.Token) + if err != nil { + return &pluginsv1.ValidateTokenResponse{Error: toPluginError(err)}, nil + } + return &pluginsv1.ValidateTokenResponse{ + Claims: &pluginsv1.TokenClaims{ + Subject: claims.Subject, + Email: claims.Email, + Roles: claims.Roles, + }, + }, nil +} + +func (s *Server) GetOIDCConfig(_ context.Context, _ *pluginsv1.GetOIDCConfigRequest) (*pluginsv1.GetOIDCConfigResponse, error) { + // Return empty config while EnsureSetup is still running so the platform's + // ready check stays false until Authentik is fully configured. + if !s.setupDone.Load() { + return &pluginsv1.GetOIDCConfigResponse{}, nil + } + cfg := s.svc.OIDCConfig() + return &pluginsv1.GetOIDCConfigResponse{ + Config: &pluginsv1.OIDCConfig{ + Authority: cfg.Authority, + ClientID: cfg.ClientID, + JwksURI: cfg.JwksURI, + Scopes: []string{"openid", "profile", "email"}, + AuthMode: cfg.AuthMode, + }, + }, nil +} + +func (s *Server) RefreshToken(ctx context.Context, req *pluginsv1.RefreshTokenRequest) (*pluginsv1.RefreshTokenResponse, error) { + tok, err := s.svc.RefreshToken(ctx, req.RefreshToken) + if err != nil { + return &pluginsv1.RefreshTokenResponse{Error: toPluginError(err)}, nil + } + return &pluginsv1.RefreshTokenResponse{Token: toTokenSet(tok)}, nil +} + +func (s *Server) EnsureAdmin(ctx context.Context, _ *pluginsv1.EnsureAdminRequest) (*pluginsv1.EnsureAdminResponse, error) { + if err := s.svc.EnsureAdmin(ctx); err != nil { + return &pluginsv1.EnsureAdminResponse{Error: toPluginError(err)}, nil + } + return &pluginsv1.EnsureAdminResponse{}, nil +} + +// ── PluginUI ────────────────────────────────────────────────────────────────── + +func (s *Server) GetUIManifest(_ context.Context, _ *pluginsv1.GetUIManifestRequest) (*pluginsv1.GetUIManifestResponse, error) { + adminURL := strings.TrimRight(s.publicURL, "/") + "/if/admin/" + return &pluginsv1.GetUIManifestResponse{ + Manifest: &pluginsv1.UIManifest{ + SettingsPages: []*pluginsv1.SettingsPage{ + { + Label: "Identity Provider", + Path: "/settings/identity", + IframeURL: fmt.Sprintf("%s#/core/applications", adminURL), + }, + }, + }, + }, nil +} + +// ── Type mapping helpers ────────────────────────────────────────────────────── + +func toTokenSet(t *domain.TokenSet) *pluginsv1.TokenSet { + return &pluginsv1.TokenSet{ + AccessToken: t.AccessToken, + RefreshToken: t.RefreshToken, + IDToken: t.IDToken, + TokenType: t.TokenType, + ExpiresIn: t.ExpiresIn, + Scope: t.Scope, + } +} + +func toPluginError(err error) *pluginsv1.PluginError { + switch { + case domain.IsUnauthorized(err): + return &pluginsv1.PluginError{Code: pluginsv1.ErrorCodeUnauthorized, Message: err.Error()} + case domain.IsConflict(err): + return &pluginsv1.PluginError{Code: pluginsv1.ErrorCodeConflict, Message: err.Error()} + default: + return &pluginsv1.PluginError{Code: pluginsv1.ErrorCodeInternal, Message: err.Error()} + } +} diff --git a/internal/adapters/oidc/client.go b/internal/adapters/oidc/client.go new file mode 100644 index 0000000..2803645 --- /dev/null +++ b/internal/adapters/oidc/client.go @@ -0,0 +1,219 @@ +// Package oidc is the outbound adapter that implements ports.IDPProvider +// by talking to Authentik over OIDC/OAuth2. +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/kleffio/idp-authentik/internal/core/domain" +) + +// Config holds the OIDC connection parameters loaded from env vars. +type Config struct { + Issuer string // e.g. "http://authentik-server:9000/application/o/kleff/" + ClientID string + ClientSecret string // optional, for confidential clients + AuthMode string // "headless" (default) or "redirect" +} + +// discovered holds the endpoints fetched from the OIDC discovery document. +type discovered struct { + TokenEndpoint string + JwksURI string +} + +// Client implements ports.IDPProvider for Authentik. +type Client struct { + cfg Config + http *http.Client + endpoints discovered +} + +// New creates a Client. Call Discover before use. +func New(cfg Config) *Client { + if cfg.AuthMode == "" { + cfg.AuthMode = "headless" + } + return &Client{ + cfg: cfg, + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +// Discover fetches the OIDC discovery document and caches the endpoints. +func (c *Client) Discover(ctx context.Context) error { + if c.cfg.Issuer == "" { + return fmt.Errorf("OIDC_ISSUER not configured") + } + discoveryURL := strings.TrimRight(c.cfg.Issuer, "/") + "/.well-known/openid-configuration" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) + if err != nil { + return fmt.Errorf("oidc discovery: %w", err) + } + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("oidc discovery: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("oidc discovery: unexpected status %d", resp.StatusCode) + } + + var doc struct { + TokenEndpoint string `json:"token_endpoint"` + JwksURI string `json:"jwks_uri"` + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return fmt.Errorf("oidc discovery: decode: %w", err) + } + if doc.TokenEndpoint == "" || doc.JwksURI == "" { + return fmt.Errorf("oidc discovery: missing token_endpoint or jwks_uri") + } + + c.endpoints = discovered{ + TokenEndpoint: doc.TokenEndpoint, + JwksURI: doc.JwksURI, + } + return nil +} + +// Login authenticates via the Resource Owner Password Credentials grant. +func (c *Client) Login(ctx context.Context, username, password string) (*domain.TokenSet, error) { + data := url.Values{ + "grant_type": {"password"}, + "client_id": {c.cfg.ClientID}, + "username": {username}, + "password": {password}, + "scope": {"openid profile email"}, + } + if c.cfg.ClientSecret != "" { + data.Set("client_secret", c.cfg.ClientSecret) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoints.TokenEndpoint, + strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("oidc login: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("oidc login: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var tok struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := json.Unmarshal(body, &tok); err != nil { + return nil, fmt.Errorf("oidc login: parse response: %w", err) + } + if tok.Error != "" { + if tok.Error == "invalid_grant" || strings.Contains(tok.ErrorDescription, "Invalid credentials") { + return nil, &domain.ErrUnauthorized{Msg: "invalid username or password"} + } + return nil, fmt.Errorf("oidc login: %s: %s", tok.Error, tok.ErrorDescription) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("oidc login: unexpected status %d", resp.StatusCode) + } + return &domain.TokenSet{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + IDToken: tok.IDToken, + TokenType: tok.TokenType, + ExpiresIn: tok.ExpiresIn, + Scope: tok.Scope, + }, nil +} + +// RefreshToken exchanges a refresh token for a new token set. +func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*domain.TokenSet, error) { + data := url.Values{ + "grant_type": {"refresh_token"}, + "client_id": {c.cfg.ClientID}, + "refresh_token": {refreshToken}, + } + if c.cfg.ClientSecret != "" { + data.Set("client_secret", c.cfg.ClientSecret) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoints.TokenEndpoint, + strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("oidc refresh: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("oidc refresh: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var tok struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if err := json.Unmarshal(body, &tok); err != nil { + return nil, fmt.Errorf("oidc refresh: parse response: %w", err) + } + if tok.Error != "" { + if tok.Error == "invalid_grant" { + return nil, &domain.ErrUnauthorized{Msg: "refresh token is invalid or expired"} + } + return nil, fmt.Errorf("oidc refresh: %s: %s", tok.Error, tok.ErrorDescription) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("oidc refresh: unexpected status %d", resp.StatusCode) + } + return &domain.TokenSet{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + IDToken: tok.IDToken, + TokenType: tok.TokenType, + ExpiresIn: tok.ExpiresIn, + Scope: tok.Scope, + }, nil +} + +// OIDCConfig returns the discovery parameters the frontend needs. +func (c *Client) OIDCConfig() domain.OIDCConfig { + return domain.OIDCConfig{ + Authority: strings.TrimRight(c.cfg.Issuer, "/"), + ClientID: c.cfg.ClientID, + JwksURI: c.endpoints.JwksURI, + AuthMode: c.cfg.AuthMode, + } +} + +// jwksURI returns the cached JWKS URI for use by the JWKS validator. +func (c *Client) jwksURI() string { + return c.endpoints.JwksURI +} diff --git a/internal/adapters/oidc/jwks.go b/internal/adapters/oidc/jwks.go new file mode 100644 index 0000000..4a29391 --- /dev/null +++ b/internal/adapters/oidc/jwks.go @@ -0,0 +1,204 @@ +package oidc + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "strings" + "sync" + "time" + + "github.com/kleffio/idp-authentik/internal/core/domain" +) + +var ( + jwksMu sync.RWMutex + jwksKeys = map[string]crypto.PublicKey{} + jwksTTL time.Time +) + +const jwksCacheDuration = 5 * time.Minute + +// ValidateToken verifies a JWT (RS256 or ES256) against Authentik's JWKS endpoint. +// Keys are cached for 5 minutes; a cache miss triggers one re-fetch. +func (c *Client) ValidateToken(ctx context.Context, rawToken string) (*domain.TokenClaims, error) { + parts := strings.Split(rawToken, ".") + if len(parts) != 3 { + return nil, &domain.ErrUnauthorized{Msg: "malformed JWT"} + } + + var header struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + } + if err := decodeSegment(parts[0], &header); err != nil { + return nil, &domain.ErrUnauthorized{Msg: "invalid JWT header"} + } + + key, err := c.getKey(ctx, header.Kid) + if err != nil { + return nil, &domain.ErrUnauthorized{Msg: err.Error()} + } + + sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, &domain.ErrUnauthorized{Msg: "invalid JWT signature encoding"} + } + + message := parts[0] + "." + parts[1] + digest := sha256.Sum256([]byte(message)) + + switch header.Alg { + case "RS256": + rsaKey, ok := key.(*rsa.PublicKey) + if !ok { + return nil, &domain.ErrUnauthorized{Msg: "key type mismatch for RS256"} + } + if err := rsa.VerifyPKCS1v15(rsaKey, crypto.SHA256, digest[:], sigBytes); err != nil { + return nil, &domain.ErrUnauthorized{Msg: "invalid JWT signature"} + } + case "ES256": + ecKey, ok := key.(*ecdsa.PublicKey) + if !ok { + return nil, &domain.ErrUnauthorized{Msg: "key type mismatch for ES256"} + } + if len(sigBytes) != 64 { + return nil, &domain.ErrUnauthorized{Msg: "invalid ES256 signature length"} + } + r := new(big.Int).SetBytes(sigBytes[:32]) + s := new(big.Int).SetBytes(sigBytes[32:]) + if !ecdsa.Verify(ecKey, digest[:], r, s) { + return nil, &domain.ErrUnauthorized{Msg: "invalid JWT signature"} + } + default: + return nil, &domain.ErrUnauthorized{Msg: fmt.Sprintf("unsupported algorithm %q", header.Alg)} + } + + var claims struct { + Sub string `json:"sub"` + Email string `json:"email"` + Exp int64 `json:"exp"` + Roles []string `json:"roles"` + // Authentik puts groups in the "groups" claim + Groups []string `json:"groups"` + } + if err := decodeSegment(parts[1], &claims); err != nil { + return nil, &domain.ErrUnauthorized{Msg: "invalid JWT claims"} + } + if claims.Sub == "" { + return nil, &domain.ErrUnauthorized{Msg: "missing sub claim"} + } + if claims.Exp > 0 && time.Now().Unix() > claims.Exp { + return nil, &domain.ErrUnauthorized{Msg: "token expired"} + } + + roles := append(claims.Roles, claims.Groups...) + return &domain.TokenClaims{Subject: claims.Sub, Email: claims.Email, Roles: roles}, nil +} + +func (c *Client) getKey(ctx context.Context, kid string) (crypto.PublicKey, error) { + jwksMu.RLock() + key, ok := jwksKeys[kid] + fresh := time.Now().Before(jwksTTL) + jwksMu.RUnlock() + + if ok && fresh { + return key, nil + } + if err := c.fetchJWKS(ctx); err != nil { + return nil, fmt.Errorf("fetch JWKS: %w", err) + } + jwksMu.RLock() + key, ok = jwksKeys[kid] + jwksMu.RUnlock() + if !ok { + return nil, fmt.Errorf("unknown key id %q", kid) + } + return key, nil +} + +func (c *Client) fetchJWKS(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.jwksURI(), nil) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + var set struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Use string `json:"use"` + Crv string `json:"crv"` + N string `json:"n"` + E string `json:"e"` + X string `json:"x"` + Y string `json:"y"` + } `json:"keys"` + } + if err := json.NewDecoder(resp.Body).Decode(&set); err != nil { + return err + } + + jwksMu.Lock() + defer jwksMu.Unlock() + for _, k := range set.Keys { + if k.Use != "sig" && k.Use != "" { + continue + } + switch k.Kty { + case "RSA": + nBytes, _ := base64.RawURLEncoding.DecodeString(k.N) + eBytes, _ := base64.RawURLEncoding.DecodeString(k.E) + if len(nBytes) == 0 || len(eBytes) == 0 { + continue + } + jwksKeys[k.Kid] = &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: int(new(big.Int).SetBytes(eBytes).Int64()), + } + case "EC": + xBytes, _ := base64.RawURLEncoding.DecodeString(k.X) + yBytes, _ := base64.RawURLEncoding.DecodeString(k.Y) + if len(xBytes) == 0 || len(yBytes) == 0 { + continue + } + var curve elliptic.Curve + switch k.Crv { + case "P-256": + curve = elliptic.P256() + case "P-384": + curve = elliptic.P384() + default: + continue + } + jwksKeys[k.Kid] = &ecdsa.PublicKey{ + Curve: curve, + X: new(big.Int).SetBytes(xBytes), + Y: new(big.Int).SetBytes(yBytes), + } + } + } + jwksTTL = time.Now().Add(jwksCacheDuration) + return nil +} + +func decodeSegment(seg string, v any) error { + b, err := base64.RawURLEncoding.DecodeString(seg) + if err != nil { + return err + } + return json.Unmarshal(b, v) +} diff --git a/internal/core/application/service.go b/internal/core/application/service.go new file mode 100644 index 0000000..6b10113 --- /dev/null +++ b/internal/core/application/service.go @@ -0,0 +1,42 @@ +package application + +import ( + "context" + + "github.com/kleffio/idp-authentik/internal/core/domain" + "github.com/kleffio/idp-authentik/internal/core/ports" +) + +// Service is the use-case coordinator for the Authentik IDP plugin. +type Service struct { + provider ports.IDPProvider +} + +// New creates a Service backed by the given IDPProvider. +func New(provider ports.IDPProvider) *Service { + return &Service{provider: provider} +} + +func (s *Service) Login(ctx context.Context, username, password string) (*domain.TokenSet, error) { + return s.provider.Login(ctx, username, password) +} + +func (s *Service) Register(ctx context.Context, req domain.RegisterRequest) (string, error) { + return s.provider.Register(ctx, req) +} + +func (s *Service) ValidateToken(ctx context.Context, rawToken string) (*domain.TokenClaims, error) { + return s.provider.ValidateToken(ctx, rawToken) +} + +func (s *Service) OIDCConfig() domain.OIDCConfig { + return s.provider.OIDCConfig() +} + +func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (*domain.TokenSet, error) { + return s.provider.RefreshToken(ctx, refreshToken) +} + +func (s *Service) EnsureAdmin(ctx context.Context) error { + return s.provider.EnsureAdmin(ctx) +} diff --git a/internal/core/domain/errors.go b/internal/core/domain/errors.go new file mode 100644 index 0000000..3be0183 --- /dev/null +++ b/internal/core/domain/errors.go @@ -0,0 +1,36 @@ +package domain + +import "errors" + +// ErrUnauthorized is returned when credentials are invalid or a token fails verification. +type ErrUnauthorized struct{ Msg string } + +func (e *ErrUnauthorized) Error() string { return e.Msg } + +// ErrNotSupported is returned for operations Authentik cannot handle via this plugin. +type ErrNotSupported struct{ Msg string } + +func (e *ErrNotSupported) Error() string { return e.Msg } + +// ErrConflict is returned when a resource already exists (e.g. duplicate username). +type ErrConflict struct{ Msg string } + +func (e *ErrConflict) Error() string { return e.Msg } + +// IsUnauthorized reports whether err is or wraps ErrUnauthorized. +func IsUnauthorized(err error) bool { + var e *ErrUnauthorized + return errors.As(err, &e) +} + +// IsNotSupported reports whether err is or wraps ErrNotSupported. +func IsNotSupported(err error) bool { + var e *ErrNotSupported + return errors.As(err, &e) +} + +// IsConflict reports whether err is or wraps ErrConflict. +func IsConflict(err error) bool { + var e *ErrConflict + return errors.As(err, &e) +} diff --git a/internal/core/domain/types.go b/internal/core/domain/types.go new file mode 100644 index 0000000..f10872a --- /dev/null +++ b/internal/core/domain/types.go @@ -0,0 +1,35 @@ +package domain + +// TokenSet is the OAuth2/OIDC token bundle returned after a successful login. +type TokenSet struct { + AccessToken string + RefreshToken string + IDToken string + TokenType string + ExpiresIn int64 + Scope string +} + +// TokenClaims carries verified identity extracted from a validated JWT. +type TokenClaims struct { + Subject string + Email string + Roles []string +} + +// OIDCConfig holds the OIDC discovery parameters the frontend needs. +type OIDCConfig struct { + Authority string // browser-reachable issuer URL + ClientID string + JwksURI string + AuthMode string // "headless" or "redirect" +} + +// RegisterRequest holds fields required to create a new user. +type RegisterRequest struct { + Username string + Email string + Password string + FirstName string + LastName string +} diff --git a/internal/core/ports/provider.go b/internal/core/ports/provider.go new file mode 100644 index 0000000..f5dff08 --- /dev/null +++ b/internal/core/ports/provider.go @@ -0,0 +1,37 @@ +package ports + +import ( + "context" + + "github.com/kleffio/idp-authentik/internal/core/domain" +) + +// IDPProvider is the outbound port through which the application talks to Authentik. +type IDPProvider interface { + // EnsureSetup waits for Authentik to be reachable, then creates the kleff + // OAuth2 application and client if they do not already exist. Idempotent. + EnsureSetup(ctx context.Context) error + + // Login authenticates a user via the Resource Owner Password Credentials grant. + // Returns ErrUnauthorized for bad credentials. + Login(ctx context.Context, username, password string) (*domain.TokenSet, error) + + // Register creates a new user account in Authentik. + // Returns ErrConflict if the username/email already exists. + Register(ctx context.Context, req domain.RegisterRequest) (string, error) + + // ValidateToken verifies a raw JWT and returns its claims. + // Returns ErrUnauthorized if the token is invalid or expired. + ValidateToken(ctx context.Context, rawToken string) (*domain.TokenClaims, error) + + // OIDCConfig returns the static OIDC discovery parameters for this provider. + OIDCConfig() domain.OIDCConfig + + // RefreshToken exchanges a refresh token for a new token set. + // Returns ErrUnauthorized if the refresh token is invalid or expired. + RefreshToken(ctx context.Context, refreshToken string) (*domain.TokenSet, error) + + // EnsureAdmin seeds the initial admin user and grants them the "admin" group. + // Safe to call multiple times (idempotent). + EnsureAdmin(ctx context.Context) error +}