Core concepts
Roles
| OAuth 2.0 | logi term | Role |
|---|---|---|
| Identity Provider | logi | Authenticates, issues tokens |
| Relying Party | partner app | Redirects, looks up userinfo |
| Resource Owner | user | Enters credentials, gives consent |
users.role: user | developer | admin.
Identifiers
User
| Field | Description |
|---|---|
apple_sub / google_sub | SSO provider stable ID. The primary lookup. |
email_address | NOT NULL, unique among active users (partial index deleted_at IS NULL). Anonymous: anon+<sha256(platform:device_uuid)[0..16]>@1pass.internal. On SSO, if the provider has no email, a synthetic apple+<hash>@1pass.internal or google+<hash>@1pass.internal is used. |
device_uuid | A DeviceCredential row (user_id, device_uuid, platform). For multi-device support — not a user identifier. |
Client (RP)
| Field | Format | Notes |
|---|---|---|
client_id | logi_ + 32 hex | Public |
client_secret | logi_secret_ + 64 hex | Shown once, stored as a bcrypt digest in the DB |
Token / Key
jti— the Access Token JWT's unique ID, used for revocation lookups.kid— the signing key identifier, supports JWKS rotation.
Apple Bundle ID
| Platform | Bundle ID | TeamID |
|---|---|---|
| iOS | com.dcodelabs.logi | 74PTNNLD4P |
| App Clip | com.dcodelabs.logi.Clip | 74PTNNLD4P |
| macOS | com.dcodelabs.logi.mac | 74PTNNLD4P |
Must match the AASA appID.
Anonymous-first (v0.4)
| Stage | User state | Identifier |
|---|---|---|
| First launch | anonymous=true, sub=nil, email_address=anon+<hash>@1pass.internal, password=random | DeviceCredential |
| First SSO | anonymous=false, sub filled in, email swapped | provider sub |
| Re-login within the 30-day grace period | Restores a deleted user via find_restorable_within_grace (account-deletion) | provider sub / email |
⚠️ Cross-provider collision (asymmetric):
- Apple path: links
apple_subto an existing Google-only user with the same email. If the user already has a differentapple_sub, it is rejected withemail_linked_to_other_apple_account. - Google path: no pre-check, so
User.create!may raiseActiveRecord::RecordNotUnique(temporary).
OAuth consent for anonymous users
Denied by default. Exceptions:
- self-RP (1pass console):
console:readis allowed for anonymous users;console:managerequires a developer. - per-RP opt-in: an RP with
oauth_applications.allow_anonymous_grants=trueaccepts anonymous grants.
Scope
| scope | userinfo keys returned |
|---|---|
profile:basic | sub, nickname, name (profile alias allowed; profile:basic recommended) |
email | sub, email, email_verified |
phone | sub, phone_number |
openid | issues an id_token + sub (enables OIDC) |
Space-separated (not comma-separated). For the full list and fields, see the Scope reference.
Identity comes from userinfo
Profile claims like email, nickname, and name are carried in the /oauth/userinfo response. The id_token carries only standard OIDC claims like sub, so if you need the profile, call userinfo with the access_token.
Consent reuse
- Re-authenticating with the same scope → skips the UI, issues a code immediately
- Scope expansion → "NEW" badge + additional consent
/settingsrevoke → the consent screen reappears on the next authentication
Token lifetimes
| Token | Expiry | Revocation |
|---|---|---|
| Authorization Code | 10 min, single-use | Automatic (consume!) |
| Access Token (JWT) | 15 min | jwt_jti DB lookup |
| Refresh Token | 30 days, with rotation | The entire chain is revoked on reuse |
| Personal API Key | No expiry (configurable) | last_used_at + manual revoke |
| Soft-deleted user grace | 30 days | Auto-restored on SSO/email re-login; after 30 days, PurgeUserJob (details) |
JWT structure
Header: { alg: "RS256", kid: "<active>", typ: "JWT" }
Payload: { iss: "logi", sub: "<user_id>", aud: "<client_id>",
exp: <15min>, iat: <now>, jti: "<uuid>", scope: "openid profile:basic email" }
Signature: RS256 over header.payloadPublic keys: /.well-known/jwks.json
Authentication mechanisms
| Mechanism | Use | Transport |
|---|---|---|
| Session cookie | Web UI | session_id=...; Secure; HttpOnly; SameSite=Lax |
| OAuth AT (JWT) | userinfo lookup | Authorization: Bearer <JWT> |
| PAK | CLI/MCP | Authorization: Bearer logi_pak_... |
| Client Basic | /oauth/token | Authorization: Basic <client_id:secret> |
| Passkey (WebAuthn) | Passwordless | ASAuthorization* / navigator.credentials |
| device_secret | Anonymous primary credential (issued once via POST /api/v1/devices, stored in the keychain) | body { "device_secret": "<urlsafe_base64>" } (no prefix) |
2FA state machine
[inactive] --setup_otp!--> [key generated] --enable_otp!(code)--> [active]
[active] --disable_otp!(current_code)--> [inactive]
[active] --login_with(otp_code)--> session.otp_verified_at = Time.current
[active] --login_with(backup_code)--> one backup code consumedPasskey + User Verification → equivalent to OTP, sets otp_verified_at automatically.
Sub Stability & canonical_sub
sub— the user.id fixed permanently at grant time. OIDC stable.canonical_sub— the currently live user.id. Updated on a merge. The RP references it when verifying a token.linked_subs— the list of absorbed user.ids. Included in the survivor's userinfo.
Details: Sub policy, RP Migration Guide.
Account Linking (identity_links)
primary_user_id (survivor canonical)
linked_user_id (absorbed, UNIQUE)
merged_via ("t1_device_link" | "t2_email_match" | "t3_otp")A DB trigger blocks chains/cycles → canonical resolution is 1-hop. For T1/T2/T3 behavior: Account Merge.