the-api-users
v0.8.1
Published
Users for the-api
Maintainers
Readme
the-api-users
Users and authentication module for the-api.
It ships with:
- e-mail registration and confirmation
- password login with access token + refresh token
- refresh, password recovery, e-mail change and phone verification flows
- users CRUD with field-level visibility/edit permissions
- avatar upload
- OAuth login/link/unlink without
passport - automatic OAuth account linking by provider id, e-mail and phone
Supported OAuth providers in this package:
- Apple
- GitHub
- Microsoft
- Twitter/X
oauthProviders are stored in the users table as jsonb. The implementation is tested with PostgreSQL.
Installation
npm i the-api-usersQuick Start
import { TheAPI, middlewares } from 'the-api';
import Roles from 'the-api-roles';
import { login, users, migrationDir } from 'the-api-users';
const roles = new Roles({
root: ['*'],
admin: [
'users.get',
'users.post',
'users.patch',
'users.delete',
'users.viewEmail',
'users.viewPhone',
'users.viewRole',
'users.viewLocale',
'users.viewStatus',
'users.viewMeta',
'users.editProfile',
'users.editEmail',
'users.editPhone',
'users.editRole',
'users.editStatus',
'users.editVerification',
'users.uploadAvatar',
],
registered: ['users.get'],
owner: [
'users.viewEmail',
'users.viewPhone',
'users.viewRole',
'users.viewLocale',
'users.viewMeta',
],
});
const theAPI = new TheAPI({
roles,
migrationDirs: [migrationDir],
routings: [
middlewares.email,
middlewares.files,
users,
login,
],
});
export default theAPI.up();Environment
See .env.example.
Core auth variables:
JWT_SECRETJWT_EXPIRES_INAUTH_DEFAULT_ROLEAUTH_VERIFIED_ROLEAUTH_UNVERIFIED_ROLEAUTH_REQUIRE_EMAIL_VERIFICATIONAUTH_CODE_EXPIRES_INAUTH_RECOVER_CODE_LENGTHAUTH_RECOVER_CODE_EXPIRES_INAUTH_REFRESH_EXPIRES_INAUTH_MAX_CODE_ATTEMPTSAUTH_PASSWORD_HASH_ALGORITHM(scryptby default, orsha256)AUTH_SCRYPT_N(16384by default, must be a power of two)AUTH_SCRYPT_R(8by default)AUTH_SCRYPT_P(1by default)AUTH_SCRYPT_MAXMEM(33554432by default)
Google OAuth:
AUTH_GOOGLE_CLIENT_IDAUTH_GOOGLE_CLIENT_SECRETAUTH_GOOGLE_REDIRECT_URIAUTH_GOOGLE_SCOPEAUTH_GOOGLE_ACCESS_TYPEAUTH_GOOGLE_PROMPT
GitHub OAuth:
AUTH_GITHUB_CLIENT_IDAUTH_GITHUB_CLIENT_SECRETAUTH_GITHUB_REDIRECT_URIAUTH_GITHUB_SCOPE
Facebook OAuth:
AUTH_FACEBOOK_CLIENT_IDAUTH_FACEBOOK_CLIENT_SECRETAUTH_FACEBOOK_REDIRECT_URIAUTH_FACEBOOK_SCOPEAUTH_FACEBOOK_FIELDS
LinkedIn OAuth:
AUTH_LINKEDIN_CLIENT_IDAUTH_LINKEDIN_CLIENT_SECRETAUTH_LINKEDIN_REDIRECT_URIAUTH_LINKEDIN_SCOPE
Microsoft OAuth:
AUTH_MICROSOFT_CLIENT_IDAUTH_MICROSOFT_CLIENT_SECRETAUTH_MICROSOFT_REDIRECT_URIAUTH_MICROSOFT_SCOPEAUTH_MICROSOFT_TENANT_ID
Twitter/X OAuth:
AUTH_TWITTER_CLIENT_IDAUTH_TWITTER_CLIENT_SECRETAUTH_TWITTER_REDIRECT_URIAUTH_TWITTER_SCOPEAUTH_TWITTER_FIELDS
Apple OAuth:
AUTH_APPLE_CLIENT_IDAUTH_APPLE_CLIENT_SECRET(optional pre-generated client secret JWT)AUTH_APPLE_REDIRECT_URIAUTH_APPLE_SCOPEAUTH_APPLE_TEAM_IDAUTH_APPLE_KEY_IDAUTH_APPLE_PRIVATE_KEY
Storage / delivery:
EMAIL_*SMS_PROVIDER,TWILIO_*FILES_FOLDERorMINIO_*
Notes:
- Legacy aliases
AUTH_GOOGLE_CALLBACK_URLandAUTH_GITHUB_CALLBACK_URLare also accepted. - GitHub login should request
user:email, otherwise the provider may not return a usable e-mail. - Apple can use either a pre-generated
AUTH_APPLE_CLIENT_SECRETJWT or dynamic secret generation viaAUTH_APPLE_TEAM_ID+AUTH_APPLE_KEY_ID+AUTH_APPLE_PRIVATE_KEY. Dynamic credentials are preferred when all three are present. - Apple browser callbacks use
response_mode=form_post, so your frontend callback should accept form posts or forward the received fields toPOST /login/apple. - Apple
AUTH_APPLE_REDIRECT_URImust exactly match the return URL used in the Apple authorization request and registered for the Services ID, even when that frontend URL forwards the callback to a different API host. - Microsoft Entra ID login uses the v2 endpoint and defaults to tenant
commonunlessAUTH_MICROSOFT_TENANT_IDis set. - If a provider is not fully configured with required
AUTH_*variables,GET /login/{service}andPOST /login/{service}respond with404the same way as an unavailable provider. - Twitter/X usually does not return e-mail in the standard OAuth profile. First-time sign-in will work only if the provider returns a usable e-mail/phone or if the request is linking to an already authenticated user.
- Use HTTPS in production and register the exact redirect URIs in the provider console.
- Set
AUTH_PASSWORD_HASH_ALGORITHM=sha256only if you want to storesha256(password + salt)password hashes. New password inserts and updates will use the selected algorithm too. AUTH_SCRYPT_*values are used only whenAUTH_PASSWORD_HASH_ALGORITHM=scrypt. Changing them changes the generated hash, so existing passwords continue to work only if they were created with the same parameters or rehashed.
OAuth Behavior
One service uses one endpoint pair:
GET /login/googlePOST /login/googleDELETE /login/google
Same for github.
Same for apple, facebook, linkedin, microsoft and twitter.
Rules implemented by the module:
- If the provider account is already linked, the user gets normal
token+refresh. - If the provider returns an e-mail or phone that belongs to an existing user, that user is logged in and the provider is linked automatically.
- If no user exists, a new user is created without a local password:
password = ""and a generated non-nullsalt. - New OAuth users get an automatic
loginfrom the e-mail local part plus a random number from1to9999. - If that
loginalready exists, the module adds another random number from1to9999to the current number and retries until a freeloginis found. - OAuth
logingeneration stops after 100 attempts and returnsLOGIN_EXISTS. - If OAuth returns e-mail or phone, that identity is treated as verified.
- If the user role was
unverified, it is promoted toregistered. - If
Authorization: Bearer <our-token>is sent toPOST /login/{service}, the provider is linked to the current user. DELETE /login/{service}removes provider data fromusers.oauthProviders.- The last available login method cannot be unlinked if the user has no local password.
Stored provider payload includes the provider user id, basic profile fields, scopes and timestamps. Provider access tokens are not persisted in users.
OAuth Flows
Browser redirect flow
- Redirect the user to
GET /login/google,GET /login/apple,GET /login/microsoftor another provider endpoint. - Provider redirects to your configured
AUTH_*_REDIRECT_URI. - Your frontend callback receives provider
codeandstate. - Frontend sends them to
POST /login/{service}and receives your APItoken+refresh.
Google example:
curl -X POST "$API/login/google" \
-H "Content-Type: application/json" \
-d '{
"code": "provider-auth-code",
"state": "provider-state-from-callback"
}'GitHub example:
curl -X POST "$API/login/github" \
-H "Content-Type: application/json" \
-d '{
"code": "provider-auth-code",
"state": "provider-state-from-callback"
}'Direct token flow
Useful for native/mobile/SPA flows where the client already has a provider token.
Supported request payloads:
- Google:
accessToken,idTokenorcode - Apple:
idTokenorcode - GitHub:
accessTokenorcode - Facebook:
accessTokenorcode - LinkedIn:
accessTokenorcode - Microsoft:
accessTokenorcode - Twitter/X:
accessTokenorcode
Twitter/X authorization-code exchange uses PKCE, so it needs the codeVerifier saved by GET /login/twitter or an explicit codeVerifier / code_verifier in the request body.
Examples:
curl -X POST "$API/login/google" \
-H "Content-Type: application/json" \
-d '{"accessToken":"google-access-token"}'curl -X POST "$API/login/github" \
-H "Content-Type: application/json" \
-d '{"accessToken":"github-access-token"}'curl -X POST "$API/login/facebook" \
-H "Content-Type: application/json" \
-d '{"accessToken":"facebook-access-token"}'curl -X POST "$API/login/linkedin" \
-H "Content-Type: application/json" \
-d '{"accessToken":"linkedin-access-token"}'curl -X POST "$API/login/microsoft" \
-H "Content-Type: application/json" \
-d '{"accessToken":"microsoft-access-token"}'Apple form_post callbacks can be forwarded as URL-encoded form data:
curl -X POST "$API/login/apple" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "code=apple-authorization-code" \
--data-urlencode "state=provider-state-from-callback" \
--data-urlencode 'user={"name":{"firstName":"Apple","lastName":"User"}}'id_token / idToken is also accepted when the Apple client flow already provides it.
Linking to an existing logged-in user
curl -X POST "$API/login/github" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"accessToken":"github-access-token"}'The response format is the same as normal login: the current user plus fresh token and refresh.
Twitter/X usually does not return e-mail; use it as a linking flow unless your provider response includes a usable e-mail or phone:
curl -X POST "$API/login/twitter" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"accessToken":"twitter-access-token"}'Listing and unlinking linked providers
curl -H "Authorization: Bearer $TOKEN" "$API/login/externals"curl -X DELETE -H "Authorization: Bearer $TOKEN" "$API/login/github"Response Format
All endpoints return the standard the-api envelope:
{
"result": {},
"meta": {},
"relations": {},
"error": false,
"requestTime": 6,
"serverTime": "2026-03-14T12:00:00.000Z"
}Successful auth responses include:
{
"result": {
"id": 1,
"email": "[email protected]",
"phone": null,
"fullName": "John Doe",
"role": "registered",
"roles": ["registered"],
"avatar": null,
"locale": "en",
"timezone": "UTC",
"isEmailVerified": true,
"isPhoneVerified": false,
"oauthServices": ["google"],
"token": "jwt...",
"refresh": "refresh-token..."
},
"error": false
}Refresh token behavior:
- A successful login reuses the current refresh token while it is not expired.
- If the stored refresh token is expired, password/OAuth login rotates it and returns a new one.
POST /login/refreshandGET /login/refreshkeep the same refresh token and extend its expiry.- Password restore and user deletion invalidate existing refresh tokens by replacing them with an expired token.
Request Examples
The examples below follow the flows covered by the test suite. Use these placeholders:
API="http://localhost:7788"
TOKEN="jwt-from-login"
REFRESH="refresh-token-from-login"
ADMIN_TOKEN="admin-jwt"
USER_ID="1"TOKEN is result.token from an auth response. REFRESH is result.refresh.
Flow map
- New password user:
POST /login/register->POST /login/register/confirm->POST /login. - Existing password user:
POST /login->POST /login/refreshorGET /login/refresh. - Forgotten password:
POST /login/forgot->POST /login/restore->POST /login. - Own account changes:
PATCH /login; confirm e-mail through/login/email, confirm phone through/login/phone. - Admin user management:
/usersendpoints with route and field permissions. - OAuth user:
GET /login/{service}-> provider callback ->POST /login/{service}, or direct tokenPOST /login/{service}.
Registration with e-mail confirmation
curl -X POST "$API/login/register" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "auth-pass-1",
"fullName": "Auth User",
"locale": "en",
"timezone": "UTC"
}'With AUTH_REQUIRE_EMAIL_VERIFICATION=true, the user is created as unverified and the response includes:
{
"result": {
"ok": true,
"email": "[email protected]",
"role": "unverified",
"refresh": "refresh-token...",
"emailConfirmationRequired": true
}
}Before confirmation, password login and refresh return EMAIL_NOT_CONFIRMED. After confirmation, the normal auth response includes a JWT and the same unexpired refresh token.
curl -X POST "$API/login/register/confirm" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"code": "code-from-email"
}'POST /login/register/check is an alias for the same confirmation flow. Use this to send a fresh code:
curl -X POST "$API/login/register/resend" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}'If AUTH_REQUIRE_EMAIL_VERIFICATION is not true, POST /login/register returns the normal auth response with token and refresh immediately.
Password login and refresh
curl -X POST "$API/login" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "auth-pass-1"
}'You can also log in with login instead of email when the user has a login name:
curl -X POST "$API/login" \
-H "Content-Type: application/json" \
-d '{
"login": "auth-user",
"password": "auth-pass-1"
}'Refresh keeps the same refresh token and returns a fresh JWT:
curl -X POST "$API/login/refresh" \
-H "Content-Type: application/json" \
-d '{"refresh":"refresh-token-from-login"}'curl "$API/login/refresh?refresh=refresh-token-from-login"Password or OAuth login also keeps the current refresh token until it expires. If the stored refresh token has expired, login rotates it and returns the replacement.
Current logged-in user:
curl -H "Authorization: Bearer $TOKEN" "$API/login/me"Password recovery
Request a recovery code. The response is { "ok": true } even when the e-mail is unknown.
curl -X POST "$API/login/forgot" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}'Set a new password with the code from e-mail:
curl -X POST "$API/login/restore" \
-H "Content-Type: application/json" \
-d '{
"code": "recover-code-from-email",
"password": "auth-pass-2"
}'After restore, existing refresh tokens are invalidated. Log in with the new password to receive a new active refresh token.
Own profile, password, e-mail and phone
Update self-editable profile fields:
curl -X PATCH "$API/login" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"fullName": "Updated User",
"locale": "uk",
"timezone": "Europe/Kyiv"
}'Change password by sending the current password as password and the replacement as newPassword:
curl -X PATCH "$API/login" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"password": "auth-pass-2",
"newPassword": "auth-pass-3"
}'Request an e-mail change:
curl -X PATCH "$API/login" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]"}'The response includes emailChangeRequested: true. Confirm it with the code sent to the new e-mail:
curl -X POST "$API/login/email" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"code":"email-change-code"}'POST /login/email/confirm is an alias. Use POST /login/email/resend with the same bearer token to send a fresh code.
Request and confirm a phone change:
curl -X PATCH "$API/login" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"phone":"+15550000001"}'curl -X POST "$API/login/phone" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"code":"phone-change-code"}'POST /login/phone/confirm is an alias. Use POST /login/phone/resend to send a fresh code.
Users CRUD and avatar
The /users module is permission-based. A token with only users.get can list users but private fields such as email, phone, password, salt and auth codes are hidden. The owner gets the visibility permissions listed in USER_OWNER_PERMISSIONS for their own record. Admin-like roles need route permissions plus field permissions such as users.viewEmail, users.editEmail and users.editVerification.
Create a user as an admin:
curl -X POST "$API/users" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "pass-1",
"fullName": "User One",
"locale": "uk",
"timezone": "Europe/Kyiv"
}'List users, sorted by id:
curl -H "Authorization: Bearer $ADMIN_TOKEN" "$API/users?_sort=id"Read one user:
curl -H "Authorization: Bearer $ADMIN_TOKEN" "$API/users/$USER_ID"Patch fields allowed by the caller's field permissions:
curl -X PATCH "$API/users/$USER_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"fullName": "Updated User",
"isEmailVerified": false
}'When an admin changes e-mail and sets isEmailVerified: false, the module generates a new register code and keeps non-unverified roles unchanged.
Upload or replace an avatar. The owner, users.patch or users.uploadAvatar can do this:
curl -X POST "$API/users/$USER_ID/avatar" \
-H "Authorization: Bearer $TOKEN" \
-F "avatar=@tests/static/1.png"Delete a user as an admin:
curl -X DELETE "$API/users/$USER_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN"Deleting a user also invalidates their stored refresh token.
Error handling
Errors use the same response envelope. Branch on result.name; examples covered by the tests include EMAIL_NOT_CONFIRMED, WRONG_CODE, INVALID_OR_EXPIRED_CODE, ACCESS_DENIED, NOT_FOUND and OAUTH_SERVICE_NOT_SUPPORTED.
Main Endpoints
Auth:
POST /login/registerPOST /login/register/confirmPOST /login/register/checkPOST /login/register/resendPOST /loginPOST /login/refreshGET /login/refreshPOST /login/forgotPOST /login/restorePATCH /loginPOST /login/emailPOST /login/email/confirmPOST /login/email/resendPOST /login/phonePOST /login/phone/confirmPOST /login/phone/resendGET /login/meGET /login/externalsGET /login/applePOST /login/appleDELETE /login/appleGET /login/googlePOST /login/googleDELETE /login/googleGET /login/githubPOST /login/githubDELETE /login/githubGET /login/facebookPOST /login/facebookDELETE /login/facebookGET /login/linkedinPOST /login/linkedinDELETE /login/linkedinGET /login/microsoftPOST /login/microsoftDELETE /login/microsoftGET /login/twitterPOST /login/twitterDELETE /login/twitter
Users CRUD:
GET /usersGET /users/:idPOST /usersPATCH /users/:idDELETE /users/:idPOST /users/:id/avatarDELETE /users/:id/avatar
Permissions
Route-level permissions:
users.getusers.postusers.patchusers.delete
Field visibility permissions:
users.viewEmailusers.viewPhoneusers.viewRoleusers.viewLocaleusers.viewStatususers.viewMeta
Field edit permissions:
users.editProfileusers.editEmailusers.editPhoneusers.editRoleusers.editStatususers.editVerificationusers.uploadAvatar
Data Model Notes
users now contains OAuth metadata:
password: empty for OAuth-created users until they set or restore a local passwordsalt: generated for OAuth-created users so stricter schemas can keep it non-nulllogin: generated for OAuth-created users from the e-mail local part plus a random numeric suffix; collisions are retried by increasing the suffix, up to 100 attemptsemail: nullable for OAuth users created from a verified phone-only identityrefresh: generated for password and OAuth users; expired values are replaced on the next successful logintimeRefreshExpired: controls refresh validity and is set to an already expired date when refresh access is intentionally invalidatedoauthProviders:jsonbmap keyed by service name
Each provider record stores:
serviceexternalIdemailphonefullNameavatargrantedScopeslinkedAtupdatedAtprofile
Development
Run tests:
bun run testBuild the package:
bun run build