DAP supports four credential mechanisms, all routing through the same
fastapi-users auth backend. Pick whichever fits your team:
| Mechanism | Best for | Where it lives |
|---|---|---|
| Email + password | Self-hosted teams, default | Engine: /auth/register, /auth/jwt/login |
| GitHub OAuth | Teams already on GitHub | Engine: /auth/github/authorize, /auth/github/callback |
| Google OAuth | Teams on Google Workspace | Engine: /auth/google/authorize, /auth/google/callback |
| API tokens | CLI, scripts, CI | Engine: /auth/api-tokens |
Cookie-based JWT sessions (dap-jwt, httpOnly) drive the dashboard;
the same JWT works as a bearer token against the engine REST API.
Opaque dap_* tokens cover scripts and machine-to-machine traffic.
POST /auth/register with {email, password}. Engine returns the new
User row. The dashboard's /signup page is the easy path:
curl -X POST http://localhost:7333/auth/register \
-H 'content-type: application/json' \
-d '{"email":"alice@example.com","password":"hunter12345"}'Policy: 8+ character password (validated server-side by
UserManager.validate_password). No complexity rules, no breach
checks. The first user must be promoted to admin (see
admin-guide.md) — or operators can bootstrap an
admin from the CLI before opening signup with dap init --admin-email=....
POST /auth/jwt/login with form-encoded username + password (the
fastapi-users default). Returns a JWT — the dashboard's
/api/auth/login route handler converts that into the dap-jwt
httpOnly cookie:
curl -X POST http://localhost:7333/auth/jwt/login \
-d 'username=alice@example.com&password=hunter12345'
# → {"access_token":"eyJ…","token_type":"bearer"}Tokens default to a 15-minute lifetime; tune via
DAP_AUTH_ACCESS_TTL_SECONDS. There is no refresh-token flow — the
short TTL bounds revocation latency without one.
The fastapi-users reset router is mounted; the engine ships no SMTP integration in v0.3. Two operational paths:
1. Admin-driven (recommended). From /admin/users an admin
edits the target user and sets a fresh password. The user logs in
with the new password and rotates it themselves.
2. Self-service with DAP_AUTH_LOG_RESET_TOKENS=1. Useful for
local-dev / single-operator instances without mail delivery. The
user posts to /auth/forgot-password with their email, the engine
emits the raw reset token at WARNING level in the engine logs (the
same stream logging.basicConfig writes to — stderr by default,
journald or docker logs under systemd / Docker), the operator
copies it out, and the user submits it back via
/auth/reset-password:
# User triggers
curl -X POST http://localhost:7333/auth/forgot-password \
-H 'content-type: application/json' \
-d '{"email":"alice@example.com"}'
# Engine logs line (default format
# "%(asctime)s %(levelname)-7s %(name)s | %(message)s"):
# 12:34:56 WARNING dap.engine.auth | user.forgot_password (token issued, email delivery TODO): user_id=<uuid> reset_token=<token>
# User submits the token (operator hands it over)
curl -X POST http://localhost:7333/auth/reset-password \
-H 'content-type: application/json' \
-d '{"token":"<token>","password":"new-password-12345"}'Grep target: dap.engine.auth | user.forgot_password.
Never leave DAP_AUTH_LOG_RESET_TOKENS=1 in production. Reset
tokens are account-takeover credentials; log aggregation routinely
ingesting them is a serious leak. The default is off.
- Go to GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
- Fill in:
- Application name:
DAP(or your team's preferred name). - Homepage URL:
https://<your-dashboard>(e.g.https://dap.example.com). - Authorization callback URL:
https://<your-engine>/auth/github/callback.
- Application name:
- Click Generate a new client secret and copy both values.
Scopes requested by the engine: read:user user:email. GitHub asks
the user to grant these on first login; once granted, subsequent
logins are silent.
# .env
DAP_OAUTH_GITHUB_CLIENT_ID=<client-id-from-github>
DAP_OAUTH_GITHUB_CLIENT_SECRET=<client-secret-from-github>
DAP_AUTH_OAUTH_REDIRECT_URL=https://dap.example.com/api/auth/oauth/callbackThe OAuth router only mounts when both CLIENT_ID and
CLIENT_SECRET are set — partial config disables the provider
without an error.
DAP_AUTH_OAUTH_REDIRECT_URL is the dashboard URL the engine
redirects the user to after the provider authenticates them. The
dashboard's catch-all /api/auth/oauth/callback handler reads the
?token= query parameter, sets the dap-jwt cookie, and lands the
user on the home page. Without it the engine returns the token as
JSON — fine for CLI flows, useless for the dashboard.
The dashboard's /login page renders a Sign in with GitHub
button when the env vars are set. Behind it:
- Dashboard →
/auth/github/authorize→ engine - Engine → GitHub authorize page → user grants
- GitHub →
/auth/github/callback→ engine - Engine resolves the account, either creating a new user or
linking the GitHub identity to an existing email (the engine sets
associate_by_email=True, so a user who signed up withalice@example.comcan later log in via the GitHub account that also returnsalice@example.com— same row, no duplicate). - Engine →
DAP_AUTH_OAUTH_REDIRECT_URL?token=...→ dashboard - Dashboard sets the cookie → user lands on
/.
- Go to Google Cloud Console → APIs & Services → Credentials.
- Configure OAuth consent screen if you haven't (
Externaluser type for general teams). - Create credentials → OAuth client ID:
- Application type: Web application.
- Authorised JavaScript origins:
https://<your-dashboard>. - Authorised redirect URIs:
https://<your-engine>/auth/google/callback.
- Copy the client ID + client secret.
Scopes requested: Google's defaults (openid email profile) — enough
to populate the user account.
# .env
DAP_OAUTH_GOOGLE_CLIENT_ID=<client-id>.apps.googleusercontent.com
DAP_OAUTH_GOOGLE_CLIENT_SECRET=<client-secret>
DAP_AUTH_OAUTH_REDIRECT_URL=https://dap.example.com/api/auth/oauth/callbackNote that DAP_AUTH_OAUTH_REDIRECT_URL is shared between GitHub and
Google — the dashboard route handler is provider-agnostic.
Same shape as GitHub, swapping the provider router prefix to
/auth/google. Google enforces verified emails server-side so the
engine flags Google-created users as is_verified=True
automatically.
Opaque dap_<urlsafe> tokens for CLIs and scripts. Each token is
returned once at creation and stored SHA-256-hashed in the DB —
the plaintext is never recoverable. Revocation is immediate.
From the dashboard: /admin/api-tokens (admin) or your
/settings/api-tokens (user-self, lands in v0.4).
From the API (JWT-only — API tokens can't mint other tokens):
# 1. Log in to get a JWT.
JWT=$(curl -s http://localhost:7333/auth/jwt/login \
-d 'username=alice@example.com&password=hunter12345' \
| jq -r .access_token)
# 2. Mint a token.
curl -X POST http://localhost:7333/auth/api-tokens \
-H "authorization: bearer $JWT" \
-H 'content-type: application/json' \
-d '{"name":"my-ci-bot","expires_in_days":90}'
# →
# {
# "id": "...",
# "name": "my-ci-bot",
# "prefix": "dap_abc1",
# "created_at": "...",
# "expires_at": "...",
# "last_used_at": null,
# "revoked_at": null,
# "token": "dap_abc12345...full_value...xyz" ← only returned now
# }The name is a label for revocation later — make it descriptive
(ci-bot-staging, backup-script-2026). expires_in_days is
optional (1–3650); omit it for tokens that don't auto-expire.
Attach as a Bearer token, same as JWT:
curl http://localhost:7333/runs \
-H 'authorization: bearer dap_abc12345...'dap CLI integration (dap auth login --token) lands in v0.4. For
v0.3 either set the header manually in a wrapper script or use
JWT-based login.
GET /auth/api-tokens returns tokens you own (each carries the
prefix field for identification, never the full value). Admins
see every token across users via GET /auth/api-tokens/admin. The
dashboard navigates to this surface at /admin/api-tokens — that
URL is a frontend route, not an engine endpoint.
DELETE /auth/api-tokens/{id} (self) or DELETE /auth/api-tokens/admin/{id} (admin-wide). Both fire an audit
event; subsequent requests with that token return 401. The
dashboard surfaces both flows under /admin/api-tokens.
Self-host teams often want to share canonical pipeline bundles
across instances — same template authored once, imported from a
private git repo into dev / STG / prod DAP without copy-pasting
JSON over Slack. The import-from-url endpoint enables exactly
this; the engine fetches a .pipeline-bundle.json from a trusted
URL and runs it through the same import code path as the existing
file-upload /pipelines/import.
This is not a public marketplace — there's no discovery surface, no community ratings, no trust audit. Operators control the allow-list themselves, only hosts they pick are reachable, and every successful import lands an audit-log entry tagged with the source URL.
Two env vars on the engine:
# Required — comma-separated literal hostnames. Empty = feature off.
DAP_TEMPLATE_REGISTRY_ALLOWED_HOSTS=raw.githubusercontent.com,gitlab.internal.com
# Optional — Bearer token attached to every outbound fetch.
# Useful for private GH/GL repos that need a PAT.
DAP_TEMPLATE_REGISTRY_AUTH_TOKEN=ghp_xxxxxxxxxxxxxxxxWhen DAP_TEMPLATE_REGISTRY_ALLOWED_HOSTS is empty (default), the
endpoint returns 422 with "import-from-url is disabled" — the
feature is strictly opt-in.
curl -X POST https://<engine>/pipelines/import-from-url \
-H "authorization: bearer dap_<your-api-token>" \
-H "content-type: application/json" \
-d '{"url":"https://raw.githubusercontent.com/myorg/dap-templates/main/code-review.pipeline-bundle.json"}'
# → 201 Created, body is the new Pipeline row (same shape as POST /pipelines/import).The bundle is validated, agents are registered, the pipeline is created and owned by the importing user (not the URL author). Each operator gets their own copy on import — same flow as the file-upload path, just sourced from a URL.
- SSRF guard: hostname must match a literal entry in the
allow-list (no wildcards, no regex).
0.0.0.0and169.254.169.254(cloud metadata) are blocked even if accidentally allow-listed. - HTTPS-only for remote hosts; only
localhost/127.0.0.1can use plain HTTP (for dev mocks). - Response cap: 10 MB. Beyond that → 413 with no parse attempt.
- Timeout: 10 s.
- Audit trail:
pipeline.imported_from_urlevent withevent_data={"url": "...", "pipeline_id": "..."}. Visible in/admin/audit-log. - Redirects disabled: the fetcher refuses to follow redirects. If your registry serves redirects, fetch the final URL directly.
- Iter 1 (this release): bare endpoint + env-driven allow-list.
- Iter 2: dashboard "Import from URL" button + static
index.jsoncatalog support (browse available templates from a registry). - Iter 3: multi-kind bundles (agent libraries, project templates, runtime presets); per-host token configuration; "promote pipeline → template" from the UI.
| Symptom | Look at |
|---|---|
401 Unauthorized on every API call |
Token expired (JWT) or revoked (API token). Re-login. |
400 Bad Request on register |
Password under 8 chars, or email already exists. |
| OAuth flow lands on JSON page, not dashboard | DAP_AUTH_OAUTH_REDIRECT_URL unset. |
| OAuth callback returns 404 | Provider's callback URL doesn't match <your-engine>/auth/<provider>/callback. |
| Multi-replica deploy: random logouts | DAP_AUTH_JWT_SECRET not pinned across replicas — each is signing with its own random. |
forgot-password succeeds but no email |
No SMTP integration in v0.3. Use admin-driven reset or DAP_AUTH_LOG_RESET_TOKENS=1 for dev. |
| User signed up via OAuth but can't password-login | Their row has no password set. Admin resets it from /admin/users. |
See also:
self-hosting.md— env vars, deployment paths.admin-guide.md—/admin/usersoperator UI.security.md— secrets handling, rotation, threat model.