Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 27 additions & 27 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Implementation Plan

Status: refreshed after the API-token prototype
Status: refreshed after the OIDC exchange migration

This plan starts from the current working prototype instead of replaying the
original phase sequence. The goal is to turn the proven shape into a usable v0
Expand All @@ -12,26 +12,25 @@ and room to reshape the next step based on what implementation teaches us.
The first API-token prototype is present in the repository:

- core `authkit` identity, principal, resource, decision, authorization fact, and port contracts
- explicit `Identity -> Principal -> Authorizer` request pipeline
- explicit `PrincipalAuthenticator -> Principal -> Authorizer` protected-resource pipeline
- memory-backed principal, local-role, identity-link, and API-token storage
- local role authorization from effective action grants
- opaque API-token issuing, verification, revocation, expiration, and last-used tracking
- router-neutral `net/http` middleware with context helpers and authorization wrappers
- thin Casbin authorizer adapter with replaceable request projection
- opt-in principal auto-provisioning for caller-approved external identities
- admin-managed CEL provisioning rules for initial role assignment from verified forwarded OIDC claims
- `examples/notes`, a runnable vertical example that wires the real packages together
- focused behavior tests around token, memory, pipeline, HTTP, local roles, Casbin, and example paths
- `testkit`, a runnable pastebin app that wires the real packages together
- focused behavior tests around token exchange, memory, pipeline, HTTP, local roles, Casbin, and testkit paths

The prototype intentionally did not include production storage, OIDC/JWT
validation, provider-trust storage, router-specific adapters, admin HTTP APIs,
or a high-level composition builder.
The prototype intentionally does not include hosted login, router-specific
adapters, built-in admin HTTP APIs, or browser session management.

## Planning Principles

- Preserve the central invariant: credentials authenticate to `Identity`,
identities resolve to internal `Principal`, and authorization operates on
`Principal + action + Resource + caller-supplied Facts`.
- Preserve the central invariant: external proof is exchanged before protected
resource access, and authorization operates on `Principal + action + Resource
+ caller-supplied Facts`.
- Keep explicit composition as the architectural foundation. Convenience APIs
can wrap the common path later, but they must not hide or replace the ports.
- Prove each new capability through a real vertical path before broadening it.
Expand All @@ -54,17 +53,17 @@ Close the obvious gaps from the API-token prototype without expanding feature
scope.

- Refresh `README.md` so it describes the working prototype and points to the
shortest path through `examples/notes`.
shortest path through `testkit`.
- Refresh stale package docs where they still describe future behavior as if it
does not exist.
- Review the public API seams exposed by `examples/notes` and make only small
- Review the public API seams exposed by `testkit` and make only small
changes that remove real friction.
- Confirm and document the current failure mapping: invalid or missing
credentials, valid but unlinked identities, denied authorization decisions,
and internal collaborator failures.
- Keep OIDC, Postgres, provider storage, and builders out of this phase.

Done when the docs match the current implementation, the example remains the
Done when the docs match the current implementation, testkit remains the
main acceptance path, and `moon ci --summary minimal` passes.

## Phase 2: Add A Postgres Storage Adapter
Expand Down Expand Up @@ -106,11 +105,12 @@ Done when an application can script common management flows without knowing the
details of every adapter, while still being able to use the lower-level
packages directly.

## Phase 4: Add OIDC/JWT Bearer Authentication
## Phase 4: Add OIDC/JWT Verification And Exchange

Add real bearer-token validation without changing the core pipeline.
Add real bearer-token validation without changing the protected-resource
pipeline.

- Add an `oidc` package for bearer-token authentication.
- Add an `oidc` package for bearer-token verification.
- Define the trusted-provider lookup contract at the smallest useful boundary.
- Validate issuer, audience, signature, expiry, and standard token validity
using current upstream OIDC/JWT libraries and docs at implementation time.
Expand All @@ -123,18 +123,18 @@ Add real bearer-token validation without changing the core pipeline.
- Prove behavior with local test issuers and JWKS fixtures, not live external
providers.

Done when a validated OIDC/JWT bearer token authenticates to `Identity`, an
unlinked identity fails principal resolution, and the existing pipeline and HTTP
middleware need no redesign.
Done when a validated OIDC/JWT bearer token verifies to `Identity`, exchange can
resolve or provision a principal, and protected-resource middleware accepts only
authkit access JWTs.

## Phase 5: Add Trusted-Provider Sources

Add provider trust adapters after the OIDC authenticator shape is proven.
Add provider trust adapters after the OIDC verifier shape is proven.

- Start with static or memory provider sources if the OIDC phase does not
already include them.
- Add Postgres-backed provider trust only once the minimal provider shape is
clear from the authenticator.
clear from the verifier.
- Keep provider records focused on trust and validation inputs such as issuer,
audiences, and optional display metadata.
- Keep management of trusted providers as Go-level service operations or
Expand All @@ -143,18 +143,18 @@ Add provider trust adapters after the OIDC authenticator shape is proven.
configuration.

Done when applications can choose static, memory, or Postgres provider trust
without changing the authenticator or core pipeline.
without changing the verifier or core pipeline.

## Phase 6: Prove Mixed Credentials Vertically

Prove that API tokens and OIDC credentials both use the same application
Prove that API tokens and OIDC exchange both use the same application
principal and authorization model.

- Add or extend an example service with both API-token and OIDC authenticators.
- Add or extend testkit with both API-token and OIDC exchange.
- Link an API-token identity and an OIDC identity to the same principal.
- Protect one route through the existing `httpauth` and Casbin path.
- Cover allowed, denied, missing credential, invalid token, wrong audience or
issuer, and valid-but-unlinked identity cases.
issuer, and unresolved identity cases.
- Use this phase to identify API seams that should be fixed before adding a
builder.

Expand All @@ -166,8 +166,8 @@ multi-credential architecture.
Add a convenience API only after explicit composition has survived production
storage and OIDC.

- Wrap common setup for authenticators, resolver, authorizer, pipeline, and
HTTP middleware.
- Wrap common setup for principal authenticators, authorizer, pipeline, and HTTP
middleware.
- Keep lower-level packages and explicit composition fully supported.
- Avoid global mutable state and hidden defaults that make production behavior
hard to inspect.
Expand Down
40 changes: 18 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# authkit

authkit is a Go library for authentication and authorization in Web API services.
It provides reusable request authentication, principal resolution, and authorization plumbing without becoming an identity provider, hosted login system, or policy framework.
It provides reusable request authentication, token exchange, principal resolution, and authorization plumbing without becoming an identity provider, hosted login system, or policy framework.

The shared auth path works end to end: a short-lived authkit access JWT authenticates to an internal principal, and an authorizer checks that principal against an action, application resource, and optional caller-supplied facts.

Expand All @@ -16,35 +16,31 @@ go get github.com/meigma/authkit
Run the vertical example:

```sh
go run ./examples/notes
go run ./testkit/cmd/testkit
```

The example prints a seed API token and starts `http://localhost:8080`.
The testkit pastebin prints a seed API token and starts `http://localhost:8080`.

Exchange the seed API token for an authkit access JWT:
Open `/login`, paste the seed token, and create a paste. The login form
exchanges the API token for an authkit access JWT carried in the temporary
`authkit_testkit_access` cookie.

```sh
ACCESS_TOKEN=$(curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
http://localhost:8080/auth/token | jq -r .access_token)
```

Use the access JWT to call the allowed route:

```sh
curl -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:8080/notes/allowed
```
The same exchange path is available to tests and applications through Go APIs:

The same access JWT is authenticated but denied by policy for another note:

```sh
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:8080/notes/denied
```go
result, err := apiTokenExchanger.Exchange(ctx, exchange.APITokenRequest{
Plaintext: seedToken,
})
if err != nil {
return err
}
_ = result.AccessToken.Plaintext
```

The example is also covered by tests:

```sh
go test ./examples/notes
go test ./testkit/...
```

## Using Authkit
Expand All @@ -61,7 +57,7 @@ Applications that need full control can use
[explicit composition](https://authkit.meigma.dev/how-to/use-explicit-composition).
Common setup tasks are covered by focused guides for
[local roles](https://authkit.meigma.dev/how-to/configure-local-roles),
[OIDC auto-provisioning](https://authkit.meigma.dev/how-to/auto-provision-oidc-principals),
[OIDC exchange and auto-provisioning](https://authkit.meigma.dev/how-to/auto-provision-oidc-principals),
and [authorization facts](https://authkit.meigma.dev/how-to/supply-authorization-facts).

The [architecture](https://authkit.meigma.dev/explanations/architecture) and
Expand All @@ -71,7 +67,7 @@ pipeline, credential independence, failure mapping, and security invariants.
## Documentation

- Docs home: [authkit.meigma.dev](https://authkit.meigma.dev/)
- Tutorial: [Learn authkit with the notes service](https://authkit.meigma.dev/tutorials/notes-service)
- Tutorial: [Learn authkit with the testkit pastebin](https://authkit.meigma.dev/tutorials/testkit-pastebin)
- How-to: [Compose HTTP authentication](https://authkit.meigma.dev/how-to/compose-http-auth)
- Explanation: [Architecture](https://authkit.meigma.dev/explanations/architecture)
- Reference: [Core contracts](https://authkit.meigma.dev/reference/core-contracts) and
Expand Down
40 changes: 0 additions & 40 deletions compose/authenticators.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"github.com/meigma/authkit"
"github.com/meigma/authkit/accessjwt"
"github.com/meigma/authkit/accessjwtauth"
"github.com/meigma/authkit/oidc"
)

// PrincipalAuthenticatorSpec builds one authkit principal authenticator for a composed service.
Expand All @@ -13,12 +12,6 @@ type PrincipalAuthenticatorSpec interface {
BuildPrincipalAuthenticator() (authkit.PrincipalAuthenticator, error)
}

// AuthenticatorSpec builds one authkit authenticator for a composed service.
type AuthenticatorSpec interface {
// BuildAuthenticator constructs the authenticator represented by the spec.
BuildAuthenticator() (authkit.Authenticator, error)
}

type accessJWTAuthenticatorSpec struct {
verifier *accessjwt.Verifier
principalFinder authkit.PrincipalFinder
Expand All @@ -38,36 +31,3 @@ func AccessJWT(
func (s accessJWTAuthenticatorSpec) BuildPrincipalAuthenticator() (authkit.PrincipalAuthenticator, error) {
return accessjwtauth.NewAuthenticator(s.verifier, s.principalFinder)
}

type existingAuthenticatorSpec struct {
authenticator authkit.Authenticator
}

// Existing wraps an already constructed authkit authenticator.
func Existing(authenticator authkit.Authenticator) AuthenticatorSpec {
return existingAuthenticatorSpec{authenticator: authenticator}
}

func (s existingAuthenticatorSpec) BuildAuthenticator() (authkit.Authenticator, error) {
return s.authenticator, nil
}

type oidcAuthenticatorSpec struct {
source oidc.ProviderSource
opts []oidc.Option
}

// OIDC configures an OIDC JWT bearer-token authenticator from source.
func OIDC(source oidc.ProviderSource, opts ...oidc.Option) AuthenticatorSpec {
copied := make([]oidc.Option, len(opts))
copy(copied, opts)

return oidcAuthenticatorSpec{
source: source,
opts: copied,
}
}

func (s oidcAuthenticatorSpec) BuildAuthenticator() (authkit.Authenticator, error) {
return oidc.NewAuthenticator(s.source, s.opts...)
}
39 changes: 3 additions & 36 deletions compose/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,9 @@ import (

// HTTPOptions configures HTTP auth composition.
type HTTPOptions struct {
// PrincipalAuthenticators are built and tried before identity authenticators.
// PrincipalAuthenticators are built and tried in order.
PrincipalAuthenticators []PrincipalAuthenticatorSpec

// Authenticators are built and tried in order.
Authenticators []AuthenticatorSpec

// Resolver maps authenticated identities to internal principals.
Resolver authkit.PrincipalResolver

// Authorizer decides whether resolved principals may act on resources.
Authorizer authkit.Authorizer

Expand All @@ -41,18 +35,12 @@ func NewHTTP(opts HTTPOptions) (*HTTP, error) {
if err != nil {
return nil, err
}
authenticators, err := buildAuthenticators(opts.Authenticators)
if err != nil {
return nil, err
}
if len(principalAuthenticators) == 0 && len(authenticators) == 0 {
return nil, errors.New("compose: at least one authenticator is required")
if len(principalAuthenticators) == 0 {
return nil, errors.New("compose: at least one principal authenticator is required")
}

pipeline, err := authkit.NewPipeline(authkit.PipelineOptions{
PrincipalAuthenticators: principalAuthenticators,
Authenticators: authenticators,
Resolver: opts.Resolver,
Authorizer: opts.Authorizer,
})
if err != nil {
Expand Down Expand Up @@ -92,24 +80,3 @@ func buildPrincipalAuthenticators(

return authenticators, nil
}

func buildAuthenticators(specs []AuthenticatorSpec) ([]authkit.Authenticator, error) {
authenticators := make([]authkit.Authenticator, 0, len(specs))
for i, spec := range specs {
if spec == nil {
return nil, fmt.Errorf("compose: authenticator spec %d is required", i)
}

authenticator, err := spec.BuildAuthenticator()
if err != nil {
return nil, fmt.Errorf("compose: build authenticator %d: %w", i, err)
}
if authenticator == nil {
return nil, fmt.Errorf("compose: authenticator %d built nil authenticator", i)
}

authenticators = append(authenticators, authenticator)
}

return authenticators, nil
}
Loading