Human identity: passkeys, ceremonies, and a provider seam that Auth0, Okta, Clerk and WorkOS all fit through
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 9
- Forks
- 0
- Avg merge
- 3h 3m
- Merged PRs (30d)
- 509
Description
Flowstate has workload identity and no human identity. That is not a missing feature so much as a missing noun, and naming it correctly the first time decides whether everything above it composes or fights.
What is true on main
Verified, because the gap is easy to overstate and the existing machinery does more than it looks like.
auth.Principalis a verified token's claims, and its own doc says so: "For workload identity this is a workload, not a person, such assystem:serviceaccount:flowstate:runner." It carries issuer, subject, audience, namespace, claims. There is no record behind it.- There is no user, account, session, credential, organization or team message in the schema.
grepoverflowstate.protofinds none of them. - No WebAuthn, no passkey, no registration, no MFA, no recovery, anywhere in the tree.
- What does exist is substantial and is the half most people would expect to be missing: OIDC discovery, JWKS fetch and rotation, trusted-issuer policy, federation, token exchange (generic OAuth and cloud), an internal issuer that mints and rotates its own signing keys,
connectrpc.com/authnmiddleware, and per-identity policy for secrets and role assumption.
So the system can already believe an assertion from an external issuer, rigorously. What it cannot do is originate one for a person, or hold any state about that person between requests.
The load-bearing decision: one Principal with a kind, not a second type
The tempting move is a User message beside Principal. It is the wrong one, and #548 explains why: the workload identity a policy gates on is already spelled three different ways across four policy surfaces, and that divergence is the single biggest source of friction in the auth stack today. Adding a human type would make it four spellings and would fork every policy surface into "which kind of caller is this" branches.
Instead a principal gains a kind — human, workload, agent — and everything else stays one vocabulary. A CEL rule that says identity.namespace == "team-a" keeps working and means the same thing regardless of who is calling; a rule that genuinely cares writes identity.kind == "human". That also gives agents a first-class spelling, which matters here more than in most systems: an agent acting for a person is neither a service account nor that person, and today it has no way to say so.
This makes #548's vocabulary unification a prerequisite rather than a neighbour.
The provider seam: offloading is mostly already built
Auth0, Okta, Clerk and WorkOS differ in their ceremonies and are identical in their output: a verified assertion about a person, from an issuer the deployment trusts. Flowstate already consumes exactly that. So the "offload it" path is largely the existing trusted-issuer machinery with a human-shaped principal on the end of it, and the plugin surface for those vendors is thin by design — discovery URL, claim mapping, and whatever their org/team claim is called.
The path that is genuinely new is owning it: WebAuthn registration and login against credentials flowstate itself stores. Passwordless-first is a security decision, not a fashion one. A passkey means there is no password database to breach, no reversible secret at rest, and no credential a phishing page can replay — so the deployment that chooses full control takes on materially less liability than the same deployment with passwords would. If we ever add passwords it should be as a deliberate, separately-argued step down.
Ceremonies, and why step-up falls out of them for free
Registration, login, recovery and step-up look like four features and are one: a ceremony is an ordered set of steps, each producing evidence, evaluated against a policy that says what evidence this action requires from this identity right now.
Written that way, step-up MFA is not a feature to build. It is a policy expression over evidence the ceremony already records — identity.evidence.factors >= 2 && now - identity.evidence.at < duration("15m") — and the same machinery answers "this tenant requires a hardware key for production deploys" and "this personal deployment requires nothing at all". The alternative, hardcoding "MFA after login", produces a system that cannot express either end of that range.
This is also where the CEL and YAML the request asks for belongs, and it should reuse #548's policy vocabulary rather than inventing a fifth environment.
Sketches
Illustrative, not the landed shape.
The kind, on the principal that already exists:
enum PrincipalKind {
PRINCIPAL_KIND_UNSPECIFIED = 0;
PRINCIPAL_KIND_HUMAN = 1;
PRINCIPAL_KIND_WORKLOAD = 2;
PRINCIPAL_KIND_AGENT = 3; // acting for a human, and neither of the above
}
message Principal {
PrincipalKind kind = 1;
string issuer = 2;
string subject = 3;
string namespace = 4; // the tenant, never taken from the request
map<string, string> claims = 5;
// Present when this principal is acting for another. An agent holds the
// human it acts for here, so a policy can gate on both without either being
// impersonated: "a human may deploy; an agent acting for a human may not".
Principal on_behalf_of = 6;
Evidence evidence = 7;
}
// Evidence is what was actually proven during the ceremony that produced this
// principal, which is what a step-up rule reads. It is never asserted by the
// caller.
message Evidence {
repeated Factor factors = 1; // passkey, oidc, recovery_code, totp
google.protobuf.Timestamp at = 2;
bool hardware_backed = 3; // an authenticator attestation said so
}
the provider seam, one interface both paths implement:
// Provider turns a completed ceremony into a verified principal. Auth0, Okta,
// Clerk and WorkOS are providers; so is the built-in one that owns its
// credentials. Nothing above this interface knows which it is talking to.
type Provider interface {
// Begin returns what the client must do next: a WebAuthn challenge, a
// redirect to an external authorization endpoint, a device code.
Begin(context.Context, CeremonyRequest) (Challenge, error)
// Finish verifies the response and returns the principal, or refuses.
// It never returns a partially verified principal: a ceremony that did
// not complete produces an error, not a principal with less evidence.
Finish(context.Context, CeremonyResponse) (*v1.Principal, error)
}
the policy, in the vocabulary #548 unifies:
# What a ceremony must prove, per action. Absent means the deployment asks for
# nothing beyond a valid session, which is the personal-scale default.
auth:
providers:
- name: okta
kind: oidc
issuer: https://acme.okta.com
namespace_claim: groups # the tenant, from the IdP, never the request
- name: passkeys
kind: webauthn # flowstate owns these credentials
require:
# Anyone may read.
- action: read
allow: ['true']
# Deploying to production wants a recent hardware-backed factor, whoever
# you are and wherever you authenticated.
- action: run
allow:
- |
identity.kind == "human"
&& identity.evidence.hardware_backed
&& now - identity.evidence.at < duration("15m")
# An agent may run, but only for a human who could have, and never in a
# namespace its principal does not belong to.
- action: run
allow:
- |
identity.kind == "agent"
&& has(identity.on_behalf_of)
&& identity.on_behalf_of.namespace == identity.namespace
what a person does, with no browser on the box:
$ flow login --server https://flowstate.example.com
choose: [1] passkey [2] okta
> 1
touch your security key…
logged in as alice@example.com (passkey, hardware-backed, expires in 8h)
$ flow login --device # headless, CI, or an agent's first run
open https://flowstate.example.com/device and enter code WDJB-MJHT
and the two paths, which meet at the same principal:
flowchart LR
subgraph own["flowstate owns it"]
P[passkey ceremony] --> W[WebAuthn verify]
W --> PR[Principal<br/>kind=human]
end
subgraph offload["an IdP owns it"]
E[Auth0 / Okta / Clerk / WorkOS] --> T[trusted-issuer verify<br/>ships today]
T --> PR
end
PR --> POL[one policy vocabulary<br/>#548]
POL --> RPC[RPC, CLI, MCP]
Constraints
- Entirely optional. A personal deployment configures no provider and gets today's behaviour. Invariant 8: a first run needs nothing.
- Fail closed, including on evidence. Absent evidence is not weak evidence; a rule that reads
identity.evidenceand finds none denies. A ceremony that half-completed produces an error, never a principal. - The namespace never comes from the request. That rule already exists for workloads and is what makes the tenant boundary real; a human principal is bound by it identically, which means the tenant comes from the IdP claim or the local account record, never from anything the client sends.
- Test that A cannot reach B. Every ceremony and every provider gets the negative direction: another tenant's user refused, an agent refused what its principal could not do, a replayed challenge refused, an expired step-up refused.
- No credential material in history or logs, with the containment shapes invariant 7 requires. WebAuthn public keys are public and safe; challenges, recovery codes and session tokens are not.
- Proto-first, including the claims and capabilities themselves, so a provider plugin declares what it can assert rather than agreeing informally.
- This sits above #549. Interactive login needs the serving surface — TLS, the OAuth endpoints, PKCE, device code — so #549's slice 1 is a hard prerequisite. There is no point issuing a session over plaintext.
Questions
- Is
PrincipalKindon the existing message the right move, rather than a separateUser? Recommended yes, and it is the decision everything else rests on. It also makes #548's vocabulary unification a prerequisite rather than a parallel track. - First provider: WebAuthn or OIDC? Recommended OIDC, because it is mostly wiring the existing trusted-issuer machinery to a human-shaped principal and it proves the seam with the least new security-critical code. WebAuthn second, as the first owned provider.
- Where do accounts live when flowstate owns them? A local store is new persistent state for a system whose only store today is Temporal. Recommended: a small pluggable account store with one obvious implementation, kept deliberately separate from run state — but this is the largest hidden cost in the whole issue and deserves its own design pass.
- Does
on_behalf_ofland in slice 1 or wait? Recommended: land the field early even if nothing populates it, because retrofitting delegation into a principal that policies already read is a breaking change to every rule. - How much of Clerk/WorkOS is worth a dedicated plugin versus being ordinary OIDC providers with claim mapping? Recommended: start with none, and add one only when a concrete deployment needs something claim mapping cannot express.
Related: #548 (one policy vocabulary — prerequisite), #549 (serving surface, OAuth 2.1, DPoP — prerequisite), #547 (the trace a login ceremony produces should be correlated like everything else).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with flowstate.proto, the existing trusted-issuer machinery, and prerequisite issues #548 and #549; the issue provides no narrower implementation entry point. Before coding, resolve the PrincipalKind, provider, account-store, delegation, and serving-surface decisions. Done requires an agreed proto-first design, provider and ceremony boundaries, fail-closed tenant and evidence behavior, and tests for cross-tenant access, replay, expiry, and credential handling.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- authentication, authorization, backend-api-design, security
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100