Competitor Tracker & Co. Docs

Using OAuth2

OAuth 2.0 / OIDC sign-in for apps that sign a person in — the short version first, then discovery, client registration, the login flow and token usage.

Use OAuth2 when your app signs a person in and acts on their behalf. If no human is at the keyboard — a script, a cron job, an MCP client — use an API key instead. Same endpoints either way.

The short version

For a typical app, the whole flow is four moves:

  1. Register a client once at https://auth.competitortracker.io/oauth/register and keep the client_id.

  2. Run the login flow (PKCE): send the user to /oauth/authorize, they sign in with an emailed code or link, you exchange the returned code at /oauth/token for an access_token and a refresh_token.

  3. Call the API with the token on every request:

    Authorization: Bearer <access_token>
  4. On a 401, refresh the token at /oauth/token and retry once.

What the token is allowed to do is set by its scopes and the member's role. The rest of this page is the detail behind those four moves.

How it fits together

Authentication for /v1/* follows OAuth 2.0 with PKCE and OpenID Connect. There is one authorization server (auth.competitortracker.io) and one or more resource servers (api.competitortracker.io, mcp.competitortracker.io). Tokens are short-lived ES256-signed JWTs (15 min) plus opaque refresh tokens (30 days, rotated on use). Our lead detective C. T. Lucky walks you through it.

Discovery

Two .well-known documents drive every client. Resource servers point at the AS via RFC 9728 protected-resource metadata. The AS itself publishes RFC 8414 metadata and OIDC discovery.

GET https://api.competitortracker.io/.well-known/oauth-protected-resource
GET https://auth.competitortracker.io/.well-known/oauth-authorization-server
GET https://auth.competitortracker.io/.well-known/openid-configuration
GET https://auth.competitortracker.io/oauth/jwks.json

The AS metadata document tells you the authorization, token, registration and revocation endpoints, the supported scopes and the supported PKCE methods (S256 only).

Dynamic Client Registration (RFC 7591)

Register a client at any time:

POST https://auth.competitortracker.io/oauth/register
Content-Type: application/json

{
  "client_name": "My App",
  "redirect_uris": ["https://app.example/oauth/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_method": "none",
  "scope": "openid email competitors:read",
  "audience": ["https://api.competitortracker.io"]
}

The response carries a client_id (and a client_secret for confidential auth methods). Public PKCE clients — browsers, mobile apps, MCP — use token_endpoint_auth_method: "none".

The login flow

  1. The user lands on /oauth/authorize?response_type=code&client_id=...&redirect_uri=...&code_challenge=...&code_challenge_method=S256&scope=openid+email&audience=https://api.... If they're already authenticated against auth.competitortracker.io via the auth-domain session cookie, they jump straight to consent. Otherwise the authorization server renders the email-entry form.
  2. User submits an email. The authorization server mints a 6-character code (A-Z minus look-alikes, 2-9) and a magic-link token, persists both as HMAC hashes and emails them. The email contains both. Either one completes the login.
  3. User either types the code on the verify form (POST /oauth/authorize/verify) or clicks the link (GET /oauth/authorize/callback). Both paths land on the same resolveUserAndOrg flow: find-or-create the user, accept any pending invite, bootstrap a personal org if no membership exists.
  4. Consent screen lists the requested scopes (with descriptions from the catalog) and, when the user has 2+ active memberships, an org picker. The selected org becomes the org_id claim on the access token.
  5. The authorization server mints an authorization code and redirects to the client's redirect_uri.
  6. The client exchanges the code at /oauth/token (PKCE verifier required) and receives { access_token, refresh_token, expires_in, id_token } in the body. No tokens in cookies: the client decides storage.

Scopes and roles

The scopes you request at registration and login, and the role of the member who signs in, together decide what each token may do. The catalog and the rules live in one place: Scopes and permissions. The scope parameter on the authorize request must stay within the set the client registered.

Using the access token

Every request to /v1/* carries:

Authorization: Bearer <access_token>

The resource server fetches the AS's JWKS, validates the signature, enforces aud (resource indicator) and iss, and pulls sub and org_id into the request context. Tokens whose jti has been revoked are rejected.

Refresh

The recommended pattern: on 401, call /oauth/token with grant_type=refresh_token and retry the original request once. Refresh rotation is mandatory. The previous refresh token is revoked atomically when a new one is issued.

POST https://auth.competitortracker.io/oauth/token
Content-Type: application/json

{
  "grant_type": "refresh_token",
  "client_id": "<client_id>",
  "refresh_token": "<refresh_token>"
}

Revocation (RFC 7009)

POST https://auth.competitortracker.io/oauth/revoke
Content-Type: application/json

{ "client_id": "<client_id>", "token": "<token>", "token_type_hint": "refresh_token" }

Refresh tokens are revoked server-side. Access tokens are added to a revocation list keyed by jti until their natural expiry. RFC 7009 guarantees a 200 response regardless of whether the token existed.

Logout

POST https://auth.competitortracker.io/logout
Content-Type: application/json

{ "refresh_token": "<token>", "access_token": "<token>" }

Revokes both tokens, clears the auth-domain session cookie and records an audit-log entry.

Error envelope

Auth failures use the common envelope:

CodeHTTPWhen
AccessTokenMissing401The Authorization: Bearer <token> header is absent.
AccessTokenInvalid401Signature, audience, issuer or expiry check failed.
AccessTokenRevoked401The jti has been revoked.
RefreshTokenInvalid401Token not found.
RefreshTokenRevoked401Token already used or explicitly revoked.
RefreshTokenExpired401Past expires_at.
OAuthInvalidGrant400Code/PKCE mismatch, redirect_uri mismatch.
OAuthInvalidScope400Requested scope outside the registered set.
LoginCodeInvalid401Wrong 6-char code.
LoginCodeExpired401TTL elapsed.
LoginCodeLockedOut429Too many failed attempts on the code.
LoginRateLimited429Too many sends from the same email or IP.
MagicLinkInvalid401Link token not found.
MagicLinkExpired401TTL elapsed.
InviteInvalid404Invite token not found.
InviteExpired410Past expires_at.
InviteAlreadyAccepted409Already accepted by the same or different user.
InviteEmailMismatch403The signed-in email doesn't match the invitee.
InviteCapReached409The organization holds the maximum of ten pending invitations.
UserNotFound404The signed-in user no longer exists.
SignupAlreadyComplete409The user has already completed the one-shot signup form.
DisposableEmailNotAllowed422The invitation recipient address is on the disposable-mailbox excludelist. Free-mail providers are fine.
InsufficientRole403The caller's organization role is too low for the requested endpoint.
OwnershipTransferRequired409The caller is the sole owner of the org and cannot leave without transferring ownership first.
NotAnOrgMember404The supplied user is not an active member of the organization (e.g., the target of an ownership transfer).
OrgMemberNotFound404The supplied user is not an active member of the current organization (role-change target).
OwnerRoleImmutable403Attempted to change the owner's role through PATCH /v1/org/members/{userId} — use transfer-ownership.
InsufficientCoinBalance402Subscribing another paid competitor would push paid count past the current coin balance.
InvalidGrantAmount422An admin coin grant carried zero or a negative amount.
OwnAppDomainImmutable409The organization's own-app domain is already set and cannot be changed.
OwnAppDomainEmailMismatch403The submitted domain doesn't match the verified email-domain on the calling user's account.
PersonalEmailDomainNotAllowed422The submitted own-app domain is a personal-mail or disposable-mail provider.

On this page