FastSchema

Authentication

View as MarkdownEdit on GitHub

FastSchema uses JWT (JSON Web Token) for authentication. The server generates tokens upon login and returns them to the client.

FastSchema uses JWT (JSON Web Token) for authentication. The server generates tokens upon login and returns them to the client. Clients must store and send tokens in every subsequent request.

Public Resources

Every FastSchema resource is protected by authentication except:

  • Resources marked as public (set in resource registration).
  • Resources granted to the guest role (which has no permissions by default).

Authentication Endpoints

Local Authentication

Login (Username/Email + Password)

Request:

POST /api/auth/local/login HTTP/1.1
Content-Type: application/json

{
  "login": "admin",
  "password": "your_password"
}

Response (Success):

{
  "token": "<access_token_jwt>",
  "expires": "2024-07-01T20:21:10Z",
  "refresh_token": "<refresh_token>",
  "refresh_token_expires": "2024-07-08T20:21:10Z"
}
  • token: JWT access token.
  • expires: Access token expiration time.
  • refresh_token: Present only if AUTH_ENABLE_REFRESH_TOKEN=true.
  • refresh_token_expires: Present only if refresh tokens enabled.

Register

Request:

POST /api/auth/local/register HTTP/1.1
Content-Type: application/json

{
  "username": "newuser",
  "email": "newuser@example.com",
  "password": "secure_password",
  "password_confirmation": "secure_password"
}

username, first_name and last_name are optional. password_confirmation is required and must match password.

A missing email, password or confirmation returns 422; mismatching passwords return 400; an email that is already taken returns 400.

Response (Success):

{
  "data": {
    "activation": "manual"
  }
}

activation echoes the configured activation method:

  • auto: the account is active immediately and can log in.
  • manual: the account stays inactive until an administrator activates it.
  • email: an activation link is emailed to the user. The activation email is sent in the background after the account is created, so a mail delivery problem is logged on the server but does not fail the registration.

Activate Account

Request:

POST /api/auth/local/activate HTTP/1.1
Content-Type: application/json

{
  "token": "<activation_token_from_email>"
}

Response:

{
  "message": "Account activated successfully"
}

Send Activation Email

Request:

POST /api/auth/local/activate/send HTTP/1.1
Content-Type: application/json

{
  "email": "user@example.com"
}

Response:

{
  "message": "Activation email sent"
}

Password Recovery Request

Request:

POST /api/auth/local/recover HTTP/1.1
Content-Type: application/json

{
  "email": "user@example.com"
}

Response:

{
  "message": "Recovery email sent"
}

Check Recovery Token

Request:

POST /api/auth/local/recover/check HTTP/1.1
Content-Type: application/json

{
  "token": "<recovery_token_from_email>"
}

Response:

{
  "valid": true
}

Reset Password

Request:

POST /api/auth/local/recover/reset HTTP/1.1
Content-Type: application/json

{
  "token": "<recovery_token_from_email>",
  "password": "new_password",
  "password_confirmation": "new_password"
}

password_confirmation is required and must match password. OTP-based flows send session_id instead of token.

Response:

{
  "data": true
}

Change Email

Starts an authenticated email change. The request re-authenticates the caller with the current password, validates the new address (format, registration policy, and uniqueness), and creates a single-use pending change. It then sends two emails: a notification to the current address and a confirmation link to the new address. The account email is not changed until the new address is confirmed.

Email change is only available for local (password) accounts.

Request:

POST /api/auth/local/email/change HTTP/1.1
Content-Type: application/json
Authorization: Bearer <access_token>

{
  "new_email": "new@example.com",
  "current_password": "your_current_password"
}

Response:

{
  "message": "A confirmation link has been sent to your new email address"
}

Confirm Email Change

Confirms a pending email change using the single-use, time-limited (24 hours) token delivered to the new address. On success, the account email is committed to the new value.

Request:

POST /api/auth/local/email/confirm HTTP/1.1
Content-Type: application/json

{
  "token": "<token_from_new_email>"
}

Response:

{
  "message": "Your email address has been updated"
}

Confirmation link

The confirmation link base defaults to <base_url>/auth/local/email/confirm. To point it at a custom page (for example, a dashboard route that calls the confirm endpoint), set email_change_url in the local provider configuration.


Session Management

Get Current User

Request:

GET /api/auth/me HTTP/1.1
Authorization: Bearer <access_token>

Response:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "login": "admin",
  "email": "admin@example.com",
  "activated": true,
  "created_at": "2024-01-01T00:00:00Z",
  "updated_at": "2024-01-01T00:00:00Z"
}

Refresh Access Token

Request:

POST /api/auth/token/refresh HTTP/1.1
Content-Type: application/json

{
  "refresh_token": "<refresh_token>"
}

Response:

{
  "token": "<new_access_token>",
  "expires": "2024-07-01T20:21:10Z",
  "refresh_token": "<new_refresh_token>",
  "refresh_token_expires": "2024-07-08T20:21:10Z"
}

Required: AUTH_ENABLE_REFRESH_TOKEN=true

Logout

Request:

POST /api/auth/logout HTTP/1.1
Authorization: Bearer <access_token>

Response:

{
  "message": "Logged out successfully"
}

Passwordless Authentication (OTP)

Request OTP Code

Request:

POST /api/auth/otp/request HTTP/1.1
Content-Type: application/json

{
  "email": "user@example.com"
}

Response:

{
  "message": "OTP sent to email",
  "expires_in": 300
}
  • expires_in: OTP expiration in seconds.

Verify OTP Code

Request:

POST /api/auth/otp/verify HTTP/1.1
Content-Type: application/json

{
  "email": "user@example.com",
  "code": "123456"
}

Response (Success):

{
  "token": "<access_token>",
  "expires": "2024-07-01T20:21:10Z",
  "refresh_token": "<refresh_token>",
  "refresh_token_expires": "2024-07-08T20:21:10Z"
}

Required: AUTH_OTP_ENABLED=true


Registration Policy

FastSchema can apply an opt-in, built-in validation policy to self-service signups. The policy runs on both local email/password registration and OAuth/social signup, just before the user row is created. It does not apply to admin-created users (content API). By default (no policy configured) nothing is blocked.

The policy supports:

FieldDescription
allowed_email_domainsAllowlist. When non-empty, only emails on these domains may register.
blocked_email_domainsDenylist of email domains to reject.
reserved_usernamesUsernames that may not be registered (e.g. admin, root, system).
normalize_emailLowercases the email domain and converts IDN domains to punycode.

Configure via environment variables

Each variable is a comma-separated list. Setting any of them enables the policy with normalize_email turned on.

VariableDescription
AUTH_REG_ALLOWED_DOMAINSAllowed email domains (allowlist).
AUTH_REG_BLOCKED_DOMAINSBlocked email domains (denylist).
AUTH_REG_RESERVED_USERNAMESReserved usernames.
AUTH_REG_ALLOWED_DOMAINS=example.com,acme.io
AUTH_REG_RESERVED_USERNAMES=admin,root,system

Configure programmatically

When embedding FastSchema as a Go framework, set the policy on AuthConfig:

app, _ := fastschema.New(&fs.Config{
  AuthConfig: &fs.AuthConfig{
    Registration: &fs.RegistrationPolicy{
      AllowedEmailDomains: []string{"example.com"},
      ReservedUsernames:   []string{"admin", "root"},
      NormalizeEmail:      true,
    },
  },
})

The built-in policy runs as the first OnPreUserRegister hook, so any custom hooks you register run afterwards on the already-normalized input.

Custom signup rules (OnPreUserRegister)

For anything beyond the basics above — invite-only gating, blocking disposable-email or free-webmail domains, custom username rules — register an OnPreUserRegister hook. The hook fires for both local and OAuth signups, may mutate Email/Username (for example, to normalize them), and returning an error rejects the registration.

app.OnPreUserRegister(func(ctx context.Context, in *fs.RegistrationInput) error {
  // Block a disposable-email domain
  if strings.HasSuffix(strings.ToLower(in.Email), "@tempmail.example") {
    return errors.BadRequest("Disposable email addresses are not allowed")
  }
  return nil
})

RegistrationInput carries the signup data:

FieldTypeDescription
EmailstringSignup email (mutable).
UsernamestringSignup username (mutable).
Providerstringlocal or the OAuth provider name.
ProviderIDstringOAuth provider subject id (empty for local).
Profilemap[string]anyRaw provider profile (OAuth only).
IsOAuthbooltrue for social-login registration.

See the Hooks reference for more detail, or Plugin Configuration to register the hook from a JS plugin.


Social / OAuth Login

Social providers (GitHub, Google, ...) are configured under AUTH (enabled_providers + per-provider client_id/client_secret). The dashboard renders a button per enabled provider; the enabled set is exposed by GET /api/auth/methods ({ "providers": [...], "local": bool, "otp": bool }, names only, never secrets). providers lists social providers only (the internal local entry is never included); local tells the dashboard whether to show the username/password form.

Flow (browser):

  1. The browser navigates to GET /api/auth/{provider}/login. The server signs a short-lived state carrier, sets a matching state cookie (CSRF binding), and redirects to the provider.
  2. After consent, the provider redirects to GET /api/auth/{provider}/callback. The server verifies the state (and the binding cookie), then resolves or creates the user.
  3. The token is not placed in a cookie by the server. The callback mints a single-use one-time code and redirects the browser to the dashboard login route with the code in the URL fragment (/dash/login#code=<one_time_code>).
  4. The dashboard reads the code and calls auth().exchange(code) (POST /api/auth/exchange), receiving the token over a direct request and persisting it through the SDK auth store. Where the token lives (cookie, localStorage, ...) is the SDK's concern, not the server's.

Because the token is delivered through the one-time code rather than a server-set cookie, the storage mechanism stays pluggable on the client. The state cookie set in step 1 is a transient CSRF binding (not the token); it is matched on the callback and lapses by its short TTL.


Federated-only login (disable local login)

Deployments that must rely on an external identity provider can turn off password and OTP login entirely, leaving federated (social/OIDC) providers as the only way in. This lets the organization enforce MFA at the identity provider; a password or an email OTP is a single factor and does not meet that requirement on its own.

Enable it with AUTH_DISABLE_LOCAL_LOGIN=true. When set:

  • POST /api/auth/local/login and the CLI local/OTP login endpoints return 403.
  • Local self-service routes (register, activate, recover, recover/reset, ...) are not registered.
  • OTP login is turned off as well, even if AUTH_OTP_ENABLED=true.
  • GET /api/auth/methods returns "local": false and "otp": false, so the dashboard shows social buttons only.
  • The local provider stays available internally for email-change and admin password reset; only the login and self-service surfaces are gated.

Bootstrapping the admin account

With local login disabled there is no password root that could sign in, so the first administrator is bootstrapped from an allowlist:

AUTH_DISABLE_LOCAL_LOGIN=true
AUTH_ADMIN_EMAILS=admin@acme.io,ops@acme.io
  • On a fresh install the setup step seeds the built-in roles without creating a password account.
  • The first federated login whose (provider-verified) email matches AUTH_ADMIN_EMAILS is granted the admin role. Subsequent logins for those emails keep their existing role.
  • Only enable this with providers that return verified emails (for example Google or GitHub), since the grant trusts the provider-supplied address.

Safety

  • At least one social provider must be configured. If AUTH_DISABLE_LOCAL_LOGIN=true and no federated provider is enabled, the server refuses to start (there would be no way to log in).
  • Break-glass: restart with AUTH_DISABLE_LOCAL_LOGIN=false to temporarily restore password login (for example to reset an admin password), then re-enable it.
  • Enabling this mode locks out any pre-existing local (password) accounts; migrate those users to a federated identity or re-invite them.
VariableDefaultDescription
AUTH_DISABLE_LOCAL_LOGINfalseReject password + OTP login; expose social providers only.
AUTH_ADMIN_EMAILS(empty)Comma-separated emails granted the admin role on first federated login.

CLI / Native-App Login (RFC 8252)

A command-line tool or native desktop app can sign a user in without ever handling the user's password or seeing the JWT in the browser. The user logs in through the normal dash browser page by any enabled method (local, social, or OTP), and the resulting credential is delivered to the app's local loopback listener via a single-use one-time code, following the OAuth 2.0 for Native Apps pattern (RFC 8252).

This feature is opt-in and disabled by default. Enable it with AUTH_CLI_LOGIN_ENABLED=true.

Why a one-time code

The token is minted server-side and stashed behind a short-lived, single-use one-time code. Only the code travels through the browser redirect; the loopback listener then exchanges the code for the token over a direct server-to-server request. The token itself never appears in any browser URL, response, or cookie during this flow. A PKCE (S256) challenge binds the exchange to the process that started it, defeating code interception.

Flow

  1. The native app starts a loopback HTTP listener on 127.0.0.1 (any free port) and generates a PKCE code_verifier.
  2. App -> POST /api/auth/cli/initiate with the loopback redirect_uri, an opaque correlation value, and code_challenge = base64url(sha256(code_verifier)). The server validates the redirect target and returns a signed carrier plus an authorize_url (/dash/login?cli=<carrier>).
  3. The app opens authorize_url in the user's browser. The dash renders the enabled login methods and the user signs in by any of them.
  4. On success the server mints the token, stores it under a one-time code, and 302-redirects the browser to the loopback redirect_uri?code=<one_time_code>&state=<correlation>.
  5. The loopback listener -> POST /api/auth/exchange with { code, code_verifier }. The server verifies PKCE, atomically consumes the code (single use), and returns the JWT exactly once.

Initiate

Request:

POST /api/auth/cli/initiate HTTP/1.1
Content-Type: application/json

{
  "redirect_uri": "http://127.0.0.1:54321/callback",
  "correlation": "a-random-opaque-value",
  "code_challenge": "BASE64URL_SHA256_OF_VERIFIER"
}

code_challenge is required (PKCE S256): native-app loopback redirects are interceptable by other local processes, so every code is bound to a verifier only the initiating process holds.

Response:

{
  "carrier": "<signed-opaque-carrier>",
  "authorize_url": "/dash/login?cli=<signed-opaque-carrier>"
}

Exchange

Request:

POST /api/auth/exchange HTTP/1.1
Content-Type: application/json

{
  "code": "<one_time_code>",
  "code_verifier": "<original_code_verifier>"
}

Response (Success):

{
  "token": "<access_token>",
  "expires": "2024-07-01T20:21:10Z",
  "refresh_token": "<refresh_token>",
  "refresh_token_expires": "2024-07-08T20:21:10Z"
}

The code is single-use and short-lived (60 seconds); a second exchange, an expired code, or a mismatched code_verifier returns 401.

Redirect target rules

redirect_uri is strictly validated before any code is issued, so the endpoint can never be used as an open redirector:

  • Loopback hosts (127.0.0.1, ::1, localhost) are always allowed on any port, over http or https. This is the normal case for native apps.
  • Non-loopback hosts are allowed only over https and only when listed in AUTH_CLI_ALLOWED_REDIRECT_HOSTS (exact host match, no wildcards).

Configuration

VariableDefaultDescription
AUTH_CLI_LOGIN_ENABLEDfalseMaster switch for the feature. When false, all cli endpoints return 403.
AUTH_CLI_ALLOWED_REDIRECT_HOSTS(empty)Comma-separated allowlist of non-loopback https redirect hosts.

Programmatically (Go framework):

app, _ := fastschema.New(&fs.Config{
  AuthConfig: &fs.AuthConfig{
    CLILogin: &fs.CLILoginConfig{
      Enabled:              true,
      AllowedRedirectHosts: []string{"app.example.com"},
    },
  },
})

Security notes

  • Disabled by default; only loopback redirects are accepted unless you explicitly allowlist https hosts.
  • One-time codes are single-use, short-lived (60s), and high-entropy; PKCE S256 (required) binds the exchange to the initiating process.
  • An exchange attempt consumes the code even when the PKCE verifier is wrong (fail-closed: this prevents brute-forcing the verifier on a live code). If an exchange fails, restart the flow.
  • The token never touches the browser in this flow - it is delivered only over the server-to-server exchange.
  • The browser-mediated step is bound to the browser that started it (a per-session state cookie is matched on the provider callback), closing login CSRF / session fixation.
  • The one-time code store is in-process (single instance). For a multi-node deployment behind a load balancer, the exchange must reach the same instance that minted the code; a shared store is a planned follow-up.

Token Usage

Clients must send the access token in every authenticated request. Two methods:

Method 1: Authorization Header

GET /api/content/users/550e8400-e29b-41d4-a716-446655440000 HTTP/1.1
Authorization: Bearer <access_token>
GET /api/content/users/550e8400-e29b-41d4-a716-446655440000 HTTP/1.1
Cookie: token=<access_token>

Configuration

Environment Variables

VariableDefaultDescription
AUTH_ENABLE_REFRESH_TOKENfalseEnable refresh token support
AUTH_REFRESH_TOKEN_LIFETIME604800Refresh token lifetime in seconds (7 days)
AUTH_ACCESS_TOKEN_LIFETIME604800Access token lifetime in seconds (7 days) or 15 minutes when refresh enabled
AUTH_OTP_ENABLEDfalseEnable OTP passwordless authentication
AUTH_OTP_LENGTH6OTP code length
AUTH_OTP_EXPIRATION300OTP expiration in seconds (5 minutes)
AUTH_OTP_MAX_ATTEMPTS3Max verification attempts before OTP expires
AUTH_REG_ALLOWED_DOMAINS(empty)Comma-separated allowlist of email domains for signup (see Registration Policy)
AUTH_REG_BLOCKED_DOMAINS(empty)Comma-separated denylist of email domains for signup
AUTH_REG_RESERVED_USERNAMES(empty)Comma-separated list of usernames that cannot be registered
AUTH_CLI_LOGIN_ENABLEDfalseEnable CLI / native-app login (see CLI / Native-App Login)
AUTH_CLI_ALLOWED_REDIRECT_HOSTS(empty)Comma-separated allowlist of extra non-loopback https redirect hosts

Email Templates

Activation and recovery emails are customizable via the fs.EmailTemplates configuration. See Configuration for details.


Error Handling

Authentication errors return structured error responses. See Error Codes Reference for details.

Example error response:

{
  "code": "401",
  "message": "Unauthorized",
  "data": {
    "error": "invalid_credentials"
  }
}

Token Expiration

  • Access tokens expire after the configured lifetime (default: 7 days).
  • Refresh tokens expire after their configured lifetime (default: 7 days).
  • The server returns 401 Unauthorized if a token is expired.
  • Use /api/auth/token/refresh to obtain a new access token (requires refresh token support enabled).

Security Notes

  • Store access tokens securely (in-memory or secure storage, never localStorage for sensitive data).
  • Refresh tokens should be stored securely and sent only with /api/auth/token/refresh requests.
  • Always use HTTPS in production.
  • Implement CSRF protections for cookie-based token transmission.
  • Change APP_KEY via environment to invalidate all active tokens (security measure).

See Also

On this page