diff --git a/.optimize-cache.json b/.optimize-cache.json index bba480b445..1406333235 100644 --- a/.optimize-cache.json +++ b/.optimize-cache.json @@ -1587,6 +1587,45 @@ "static/images/docs/network/edges-map.png": "12ecc1ea200905ba75eb7cfd17055a156fd51fccf746869a1058f923dfd7ac1b", "static/images/docs/network/pops-map.png": "205ead599703cf47d0df316db8fcc4f48d5eed01508109fc740d17914275e9ab", "static/images/docs/network/regions-map.png": "c65f1423ab19c3048bf8bf93117e8f2e1d13a2bc705c00307de7ee821e5668a1", + "static/images/docs/oauth-server/dark/diagram-authorization.png": "5f82a31fb0ec0b753b7b0a2dc6b4f044609847c870205cc32e9ee87767db2d8f", + "static/images/docs/oauth-server/dark/diagram-clients.png": "00de79e7ef87e325309ae33c34a21395b4f3906bfa0809698f63214e3b9457ef", + "static/images/docs/oauth-server/dark/diagram-device-flow.png": "e555617a52b852a33df22d2c991ee8948e0e64599775efea533327cf79d8e37c", + "static/images/docs/oauth-server/dark/diagram-overview.png": "cf64794cfb2d0204d4cff2f0061923a73f5d77a8333bfba6ddb693f28a80671d", + "static/images/docs/oauth-server/dark/diagram-scopes.png": "9e1aeae9645bb68b581881596393db446402f76ecb4740dd05aca69536324876", + "static/images/docs/oauth-server/dark/diagram-tokens.png": "060e52ff548a7a337af7a62ac89f0f832034255cf4d3b3652d15384b989622fd", + "static/images/docs/oauth-server/dark/oauth2-server-apps-empty.png": "b81617a77c6489bc93cfe3c118c2fd075eebda850fc5803cba4b1b62792269f1", + "static/images/docs/oauth-server/dark/oauth2-server-apps-list.png": "a0b1a9bcc5708bb48f7d4684d754cfc8af8e666f808e11d00fc42182ee7ccf6e", + "static/images/docs/oauth-server/dark/oauth2-server-create-app.png": "7ecd5625aa964ab77b87440e52bc866a4894dffe5183a0e86f7a46b8c438cee7", + "static/images/docs/oauth-server/dark/oauth2-server-discovery.png": "e688439f87c4ae73086b76ee094e036d5bddb14b9431aaad5901d281ab3ca413", + "static/images/docs/oauth-server/dark/oauth2-server-secret-created.png": "d675fe574706d58b224ce823c8904145482472f64ab940a8bb37b92fcc33008e", + "static/images/docs/oauth-server/dark/oauth2-server-settings-disabled.png": "ad51d531eaa3736839859097c024bca41a28d1339ceef773e376c06936e040ee", + "static/images/docs/oauth-server/dark/oauth2-server-settings.png": "18da0340416739f803b8c04d66c191e92e7eafa70cd301acb1e269d8dd5a0c81", + "static/images/docs/oauth-server/dark/oauth2-server-token-lifetimes.png": "46743e2f0a3e8c126d590809668a37c2beb1f1c5483002839d9a15eca9e110d4", + "static/images/docs/oauth-server/dark/scopes-defined.png": "4723658a478b30e4f97372661d4ed7a9ba14d1cc7f0b56d4f5bf6148b5326cf1", + "static/images/docs/oauth-server/diagram-authorization.png": "84371bcc0218c49124772bb1efffd98880195f580dd53350c57c93ac7a0cea94", + "static/images/docs/oauth-server/diagram-clients.png": "d288a77ddede75c405419df9e3477d471ee7cb57ed63efe6c005cdf2b35098ac", + "static/images/docs/oauth-server/diagram-device-flow.png": "2e887ebd32a76f9ba1aae2f377f7e5c74f4456f36c91ee767aeb4a3dc307a7ee", + "static/images/docs/oauth-server/diagram-overview.png": "71488c2c882c2befa2384cf20aaff7f681bf55b52f3247a4b0f3e73b3684f412", + "static/images/docs/oauth-server/diagram-scopes.png": "6bc6b498f71b1ec5ac1fdac3cddedca3749b2e50c8452e587235b366b577638c", + "static/images/docs/oauth-server/diagram-tokens.png": "62de8f079c3b585c312c13c830fc53d957e8fc8e6432eaa0d33c5a1942e331bc", + "static/images/docs/oauth-server/guide/taskflow-consent.png": "5c4f4cbc6b225c60d5d6e2f1616abe45f0e6b66a5014438961fca98db349bd18", + "static/images/docs/oauth-server/guide/taskflow-login.png": "5d0013b31b211efe4d3a2f6a9f224a5b547d3ef1370e507b9a183bb276fc90e1", + "static/images/docs/oauth-server/guide/vantage-dashboard.png": "b2e95c34ec9c19be090e5f3384e0c3278dd840059b3f015ccdb13a16c135dde1", + "static/images/docs/oauth-server/guide/vantage-landing.png": "dc597717bcc93de02b2e5336a0f05d743ea66e6d91cef40fbdc61f8380577bec", + "static/images/docs/oauth-server/oauth2-server-apps-empty.png": "6f7b5c2021df2db7a3a1c4da3dfcf9b2ff119e98450e755eb64f03263030da0a", + "static/images/docs/oauth-server/oauth2-server-apps-list.png": "e3f38509e45e502f742e7532ae1a19bdd35b0891821b6a81601b7960ff16b38c", + "static/images/docs/oauth-server/oauth2-server-create-app.png": "fafb26a8844e4d792a8105c1da2f4c403df117887eeda0fd1c4ea3384929facd", + "static/images/docs/oauth-server/oauth2-server-discovery.png": "5b75f8e780cac92a68fda24a9686ba89ce83d89ac66e5f651428b4c62bb81cbd", + "static/images/docs/oauth-server/oauth2-server-secret-created.png": "5a6d48595018a8faaa26aeafe099c3dda23613a35e49884decbecb0029449168", + "static/images/docs/oauth-server/oauth2-server-settings-disabled.png": "648091d9d81308ab93dd456f172fa1c1977c2fbce9b8aa03893398bdefe10595", + "static/images/docs/oauth-server/oauth2-server-settings.png": "914aa6511caedb568f199eacef837366c523f78125c551afa992e39098707431", + "static/images/docs/oauth-server/oauth2-server-token-lifetimes.png": "895bf62c7f5171c8f244fda37b516760266c47447f2c66c93c3c812139823b96", + "static/images/docs/oauth-server/scopes-defined.png": "ae1d03cc7cd585f52b6445f5b6506a77f3cb47f50f5677c23e5ff650667942b4", + "static/images/docs/oauth-server/scopes-guide/taskflow-consent-choice.png": "27a8ebf167728a50059bf315bc9676e3c5ec9f32f47eb70ea7c4e4715fadbf99", + "static/images/docs/oauth-server/scopes-guide/taskflow-consent-scopes.png": "ad7975be44e1840e85d994c3a12e19bcbe63e4eec5ba4bec9028c6683db3ae60", + "static/images/docs/oauth-server/scopes-guide/vantage-tasks-dashboard.png": "a621e10d803c25408eafcb0393e934080c4a2fd00e3230ad2a8cab0fbc84d778", + "static/images/docs/oauth-server/scopes-guide/vantage-write-denied.png": "7e2f32309bad509c9920e5725f8b49654e4dbc19699ee6af309b174e955bd2fd", + "static/images/docs/oauth-server/scopes-guide/vantage-write-granted.png": "1565538a08f94180a7ff35df2cad426c962609eeeae021e2fc8c076242882e1e", "static/images/docs/platform/add-platform.png": "5a05bb9d75a8d5270bfa5e67df7e6de20a9fad174476a112b5bdab72e7bdad30", "static/images/docs/platform/create-api-key.png": "7661b3845e13704643f8ff4f763faa8e61efb90878c3ffa7466ece0910b8ecab", "static/images/docs/platform/create-webhook.png": "77e08173da6ac534524e025433cf75e532d853a135944a7c6ba2278357d88b2d", diff --git a/src/lib/utils/code.ts b/src/lib/utils/code.ts index 87503c1066..f58058218a 100644 --- a/src/lib/utils/code.ts +++ b/src/lib/utils/code.ts @@ -54,6 +54,8 @@ const languages = { java: java, cpp: cpp, bash: bash, + curl: bash, + hurl: http, powershell: powershell, cmd: dos, yaml: yaml, diff --git a/src/lib/utils/references.ts b/src/lib/utils/references.ts index ad3358eebf..bc2dda57f8 100644 --- a/src/lib/utils/references.ts +++ b/src/lib/utils/references.ts @@ -136,6 +136,8 @@ export const platformMap: Record = { csharp: 'C#', cpp: 'C++', bash: 'Bash', + curl: 'cURL', + hurl: 'Hurl', powershell: 'PowerShell', cmd: 'CMD', yaml: 'YAML', diff --git a/src/routes/blog/post/announcing-oauth2-server/+page.markdoc b/src/routes/blog/post/announcing-oauth2-server/+page.markdoc new file mode 100644 index 0000000000..593359ce33 --- /dev/null +++ b/src/routes/blog/post/announcing-oauth2-server/+page.markdoc @@ -0,0 +1,265 @@ +--- +layout: post +title: "Announcing the Appwrite OAuth2 server: Turn your project into an identity provider" +description: Your Appwrite project can now act as an OAuth 2.1 and OpenID Connect provider, so other apps can offer Sign in with your product and request scoped access to your APIs. +date: 2026-07-15 +cover: /images/blog/announcing-oauth2-server/cover.png +timeToRead: 6 +author: matej-baco +category: announcement +featured: false +callToAction: true +draft: true +faqs: + - question: "What is the Appwrite OAuth2 server?" + answer: "The OAuth2 server lets your Appwrite project act as an OAuth 2.1 and OpenID Connect provider. Third-party apps register as clients, send your users through an authorization flow you control, and receive tokens they can use to sign users in or call your APIs with scoped access." + - question: "How is the OAuth2 server different from Appwrite's OAuth providers?" + answer: "OAuth providers let your users sign in to your app with Google, GitHub, and other identity providers. The OAuth2 server is the other direction: your project becomes the identity provider, and other apps integrate with you the same way you would integrate with Google." + - question: "Which OAuth standards does the Appwrite OAuth2 server support?" + answer: "The server implements OAuth 2.1 with PKCE, OpenID Connect Core, discovery metadata, token introspection and revocation, the device authorization flow, pushed authorization requests, dynamic client registration, JWT access tokens, and rich authorization requests." + - question: "Can users register OAuth2 clients without the Appwrite Console?" + answer: "Yes. The apps service is available in the Client SDKs, so any signed-in user on your project can register and manage their own clients. This lets you build a self-serve developer portal on top of your project, and the standard dynamic client registration endpoint is also supported." + - question: "Can I define custom scopes for my own APIs?" + answer: "Yes. Alongside the built-in openid, profile, and email scopes, you can define scopes like tasks.read for your own APIs. Your resource server verifies the access token and checks the granted scopes before serving a request." + - question: "Is the OAuth2 server available on self-hosted Appwrite?" + answer: "The OAuth2 server is available on Appwrite Cloud. Every project includes it, and you can enable it from the Auth section of the Console." +--- + +If you have ever added **Sign in with Google** or **Sign in with GitHub** to an app, you have been on the consuming side of OAuth. Being on the other side, the side that issues the tokens, has always been much harder. Building an authorization server means implementing authorize and token endpoints, PKCE, consent, token rotation, client management, and a stack of specifications that are easy to get subtly wrong. + +That work is now part of your Appwrite project. With the **OAuth2 server**, your project can act as an **OAuth 2.1 and OpenID Connect provider**. Third-party developers register their apps as clients, your users approve what those apps can access, and your project issues the tokens. Integrators can offer **Sign in with your product** and request scoped access to your APIs, using the same standard flows they already know from Google or GitHub. + +# What your project provides + +Once enabled, your project exposes the full set of endpoints an OAuth or OIDC integration expects: authorization, token, userinfo, introspection, revocation, logout, device authorization, pushed authorization requests, and dynamic client registration. All of them are described by a discovery document, so most integrators point their OAuth library at one URL and never read your endpoint list: + +```text +https://.cloud.appwrite.io/v1/oauth2//.well-known/openid-configuration +``` + +Two design decisions matter more than the endpoint list. + +First, **you own the consent screen**. The OAuth2 server handles the protocol, but the page where users review and approve a request is a page you host, built with your components and your branding. Users approving access to their data never leave your product's design language. + +Second, **client registration is an API, not a Console form**. The `apps` service is available in the Client SDKs, so any signed-in user on your project can register an app. That is what makes a self-serve developer portal possible: integrators create their clients, manage their secrets, and rotate credentials inside your product, while the Console remains your admin view. + +# Enable the server in the Console + +The OAuth2 server lives in the **Auth** section of the Console, under the **OAuth2 server** tab. Turn it on, set the authorization URL to the page that will host your consent screen, and add any custom scopes your APIs support. The `openid`, `profile`, and `email` scopes are always included. + +Once enabled, the Console shows your project's discovery URL, and opening it in a browser confirms the server is live. + +![The OIDC discovery URL in the Appwrite Console](/images/docs/oauth-server/dark/oauth2-server-discovery.avif) + +# Register a client + +The rest of this post follows two apps: + +- **TaskFlow**: your product, a task manager built on Appwrite, acting as the OAuth2 provider. +- **Vantage**: a third-party analytics dashboard that wants to offer **Sign in with TaskFlow**. + +Vantage registers as a client with a name and a redirect URI, the URL where users return to Vantage with an authorization code after approving access. You can create the client in the **Apps** sub-tab of the Console, or let integrators like Vantage register their own from code: + +{% multicode %} +```client-web +import { Client, Apps, ID } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const app = await apps.create({ + appId: ID.unique(), + name: 'Vantage', + redirectUris: ['http://vantage.localhost/auth/redirect'], +}); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Apps apps = Apps(client); + +App app = await apps.create( + appId: ID.unique(), + name: 'Vantage', + redirectUris: ['http://vantage.localhost/auth/redirect'], +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let apps = Apps(client) + +let app = try await apps.create( + appId: ID.unique(), + name: "Vantage", + redirectUris: ["http://vantage.localhost/auth/redirect"] +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.ID +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val app = apps.create( + appId = ID.unique(), + name = "Vantage", + redirectUris = listOf("http://vantage.localhost/auth/redirect") +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.ID; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.create( + ID.unique(), // appId + "Vantage", // name + List.of("http://vantage.localhost/auth/redirect"), // redirectUris + null, // description (optional) + null, // clientUri (optional) + null, // logoUri (optional) + null, // privacyPolicyUrl (optional) + null, // termsUrl (optional) + null, // contacts (optional) + null, // tagline (optional) + null, // tags (optional) + null, // images (optional) + null, // supportUrl (optional) + null, // dataDeletionUrl (optional) + null, // postLogoutRedirectUris (optional) + null, // enabled (optional) + null, // type (optional) + null, // deviceFlow (optional) + null, // teamId (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Apps, ID } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const app = await apps.create({ + appId: ID.unique(), + name: 'Vantage', + redirectUris: ['http://vantage.localhost/auth/redirect'], +}); +``` +{% /multicode %} + +Clients come in two types: + +- **Confidential** clients run on a server, hold a client secret, and authenticate the token exchange with it. +- **Public** clients, such as mobile and single-page apps, cannot keep a secret and use PKCE instead. + +Beyond the basics, each client carries consent screen branding, post-logout redirect URIs, and an optional device flow, and clients can be transferred to teams so a whole organization manages them together. + +# Run a sign-in + +The default flow is the standard authorization code flow. When a user clicks **Sign in with TaskFlow**, Vantage sends their browser to TaskFlow's authorization endpoint with its client ID, redirect URI, and requested scopes: + +```text +https://.cloud.appwrite.io/v1/oauth2//authorize + ?client_id= + &redirect_uri=http://vantage.localhost/auth/redirect + &response_type=code + &scope=openid profile email + &state= +``` + +Appwrite validates the request and redirects the user to TaskFlow's consent screen with a grant ID. The page shows what Vantage is asking for, and when the user approves, Appwrite sends them back to Vantage with an authorization code. Vantage's server then exchanges the code for tokens: + +{% multicode %} +```curl +curl -X POST 'https://.cloud.appwrite.io/v1/oauth2//token' \ + -H 'Content-Type: application/json' \ + -d '{ + "grant_type": "authorization_code", + "code": "", + "redirect_uri": "http://vantage.localhost/auth/redirect", + "client_id": "", + "client_secret": "", + "code_verifier": "" + }' +``` +```hurl +POST https://.cloud.appwrite.io/v1/oauth2//token +Content-Type: application/json +{ + "grant_type": "authorization_code", + "code": "", + "redirect_uri": "http://vantage.localhost/auth/redirect", + "client_id": "", + "client_secret": "", + "code_verifier": "" +} +``` +{% /multicode %} + +The response contains a JWT access token, a refresh token with rotation, and an OIDC ID token. A call to the userinfo endpoint with the access token returns the user's profile, which is everything Vantage needs to sign the user in on its side. + +# Protect your own APIs with custom scopes + +Sign-in is the starting point. The larger opportunity is scoped access to your APIs. TaskFlow defines scopes like `tasks.read` or `tasks.write` on the server, Vantage requests them during authorization, and users see exactly what they are granting on the consent screen before Vantage can read a single task. + +Access tokens are signed JWTs that carry the granted scopes, so your API verifies a request with your project's public keys from the JWKS endpoint and checks the scope before serving it. No call back to Appwrite is needed on the hot path. For fine-grained cases that scopes cannot express, such as an app requesting access to one specific resource, the server also supports rich authorization requests (RFC 9396). + +# Standards support + +Every flow follows the corresponding specification, so off-the-shelf OAuth and OIDC libraries work without custom adapters: + +| Standard | What it covers | +| --- | --- | +| OAuth 2.1 with PKCE | Authorization code flow with proof key for code exchange | +| OpenID Connect Core | ID tokens, userinfo, and RP-initiated logout | +| RFC 8414 | Discovery metadata for automatic client configuration | +| RFC 7662 and RFC 7009 | Token introspection and revocation | +| RFC 8628 | Device authorization flow for TVs and CLIs | +| RFC 9126 | Pushed authorization requests | +| RFC 7591 | Dynamic client registration | +| RFC 9068 | JWT access tokens | +| RFC 9396 | Rich authorization requests | + +# Get started + +The OAuth2 server is available on Appwrite Cloud. Enable it from the **Auth** section of your project, register a client, and run your first sign-in in a few minutes with the quick start. + +- [OAuth2 server documentation](/docs/partners/oauth-server) +- [Quick start: run your first authorization flow](/docs/partners/oauth-server/quick-start) +- [Tutorial: add Sign in with your product to an app](/docs/partners/oauth-server/sign-in-with-your-product/step-1) +- [Tutorial: protect an API with custom scopes](/docs/partners/oauth-server/custom-scopes/step-1) diff --git a/src/routes/docs/Sidebar.svelte b/src/routes/docs/Sidebar.svelte index 1dd3ea69ec..7d6432d285 100644 --- a/src/routes/docs/Sidebar.svelte +++ b/src/routes/docs/Sidebar.svelte @@ -101,6 +101,13 @@ href: '/docs/partners/project', icon: 'icon-briefcase', isParent: true + }, + { + label: 'OAuth2 server', + href: '/docs/partners/oauth-server', + icon: 'icon-key', + isParent: true, + new: isNewUntil('31 Aug 2026') } ] }, diff --git a/src/routes/docs/partners/oauth-server/+layout.svelte b/src/routes/docs/partners/oauth-server/+layout.svelte new file mode 100644 index 0000000000..047de6bb1a --- /dev/null +++ b/src/routes/docs/partners/oauth-server/+layout.svelte @@ -0,0 +1,68 @@ + + + + + + diff --git a/src/routes/docs/partners/oauth-server/+page.markdoc b/src/routes/docs/partners/oauth-server/+page.markdoc new file mode 100644 index 0000000000..48bffe9978 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/+page.markdoc @@ -0,0 +1,92 @@ +--- +layout: article +title: OAuth2 server +description: Turn your Appwrite project into an OAuth 2.1 and OpenID Connect (OIDC) provider so third-party apps can sign in with your product. +back: /docs +--- + +Your Appwrite project can act as an **OAuth 2.1 and OpenID Connect provider** (OIDC provider). When you enable the OAuth2 server, third-party apps register as clients, send your users to a consent screen you host, and receive tokens your project issues. Your project becomes an identity provider that any standards-compliant OAuth or OIDC library can integrate with: the same way apps offer "Sign in with Google" or "Sign in with GitHub", integrators can offer **Sign in with your product**. + +{% only_dark %} +![OAuth2 server settings in the Appwrite Console](/images/docs/oauth-server/dark/oauth2-server-settings.avif) +{% /only_dark %} +{% only_light %} +![OAuth2 server settings in the Appwrite Console](/images/docs/oauth-server/oauth2-server-settings.avif) +{% /only_light %} + +# How it works {% #how-it-works %} + +{% only_dark %} +![A third-party app obtaining tokens from your project's OAuth2 server and calling your APIs](/images/docs/oauth-server/dark/diagram-overview.avif) +{% /only_dark %} +{% only_light %} +![A third-party app obtaining tokens from your project's OAuth2 server and calling your APIs](/images/docs/oauth-server/diagram-overview.avif) +{% /only_light %} + +The OAuth2 server has three moving parts. + +1. **The authorization server.** Enabling the server on your project exposes the full set of endpoints: discovery, JWKS, authorize, approve and reject, token, userinfo, introspect, logout, and revoke. More advanced integrations also get device authorization, pushed authorization, and dynamic client registration. +2. **Clients.** Each third-party app registers as a [client](/docs/partners/oauth-server/clients), either confidential (it has a backend that can hold a secret) or public (a browser or mobile app that cannot). A client declares the redirect URIs it can return to and the post-logout redirect URIs it can end sessions at, chooses whether the device flow is available (off by default), and carries what your consent screen shows for it: a name, logo, tagline, and description, along with directory details like tags, images, and contact and privacy information. +3. **The consent screen.** You host a consent screen at an authorization URL you configure. When a user authorizes a client, Appwrite redirects the user to your screen, your screen confirms the grant, and Appwrite issues an authorization code the client exchanges for tokens. + +The flow follows the OAuth 2.1 authorization code grant, including PKCE for public clients, plus the OpenID Connect layer for identity. Because the server is spec-compliant, integrators can point any OAuth or OIDC client library at your project's discovery URL and it works without Appwrite-specific code. The URL is the same for every project, with only the project ID changing: + +```text +https://.cloud.appwrite.io/v1/oauth2//.well-known/openid-configuration +``` + +# Standards support {% #standards %} + +The server follows the OAuth 2.1 security practices: the password and implicit access-token grants are not supported, refresh tokens rotate on every use with reuse detection, and redirect URIs match exactly. The implemented standards: + +| Standard | What it covers | +| --- | --- | +| [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) | OAuth 2.0, the core of the sign-in implementation. | +| [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750) | Bearer token usage, including `WWW-Authenticate` error responses. | +| [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009) | Token revocation. | +| [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) | Dynamic client registration, useful for MCP server support. | +| [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) | PKCE with `S256` hashing. Required for public clients, and a project setting can require it for confidential clients too. | +| [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) | Token introspection, for building APIs that validate access tokens. | +| [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) | OAuth for native apps: loopback redirects match on any port, and public clients can register private-use scheme redirect URIs. | +| [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) | Authorization server metadata, published as the OpenID Connect discovery document. | +| [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628) | Device authorization grant, to sign in on input-constrained devices like TVs and remote servers. | +| [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) | Resource indicators, so a token can be restricted to one of several APIs behind the same provider. | +| [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517), [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519), [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068) | JWT access tokens with an audience, and the JWK Set published at a well-known URL. | +| [RFC 9126](https://datatracker.ietf.org/doc/html/rfc9126) | Pushed authorization requests, which keep the authorize URL short where it is visible as text, such as in a CLI or an MCP client. | +| [RFC 9396](https://datatracker.ietf.org/doc/html/rfc9396) | [Rich authorization requests](/docs/partners/oauth-server/scopes#rich-authorization-requests), structured constraints where a flat scope says too little. | +| [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) | The identity layer: ID tokens and the userinfo endpoint. | +| [OpenID Connect RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) | The logout endpoint, with per-client post-logout redirect URIs. | + +# Concepts {% #concepts %} + +{% cards %} +{% cards_item href="/docs/partners/oauth-server/quick-start" title="Quick start" %} +Enable the server, register a client, and run your first sign-in end to end. +{% /cards_item %} +{% cards_item href="/docs/partners/oauth-server/clients" title="Clients" %} +Register confidential and public OAuth clients and manage their secrets. +{% /cards_item %} +{% cards_item href="/docs/partners/oauth-server/authorization" title="Authorization" %} +The authorization code flow, PKCE, and hosting your own consent screen. +{% /cards_item %} +{% cards_item href="/docs/partners/oauth-server/tokens" title="Tokens" %} +Access, refresh, and ID tokens, their lifetimes, introspection, and revocation. +{% /cards_item %} +{% cards_item href="/docs/partners/oauth-server/scopes" title="Scopes" %} +The built-in OpenID scopes and the custom scopes your clients can request. +{% /cards_item %} +{% cards_item href="/docs/partners/oauth-server/device-flow" title="Device flow" %} +Authorize TVs, CLIs, and other input-constrained devices. +{% /cards_item %} +{% /cards %} + +# Guides {% #guides %} + +{% cards %} +{% cards_item href="/docs/partners/oauth-server/sign-in-with-your-product/step-1" title="Sign in with your product" %} +Build a full sign-in with your product experience end to end, from the consent screen to the token exchange. +{% /cards_item %} +{% cards_item href="/docs/partners/oauth-server/custom-scopes/step-1" title="Protect your API with custom scopes" %} +Define custom scopes, request them from a client, and enforce them on your own API. +{% /cards_item %} +{% /cards %} diff --git a/src/routes/docs/partners/oauth-server/authorization/+page.markdoc b/src/routes/docs/partners/oauth-server/authorization/+page.markdoc new file mode 100644 index 0000000000..127f12ba1d --- /dev/null +++ b/src/routes/docs/partners/oauth-server/authorization/+page.markdoc @@ -0,0 +1,660 @@ +--- +layout: article +title: Authorization +description: How clients request authorization and how to host a consent screen for your Appwrite OAuth2 server. +back: /docs/partners/oauth-server +--- + +Authorization is the step where a user allows a client to act on their behalf. Appwrite's OAuth2 server uses the authorization code flow. Public clients protect the flow with PKCE. Confidential clients authenticate with a client secret and can also use PKCE when your project requires it. + +# The authorization code flow {% #flow %} + +{% only_dark %} +![The authorization code flow between the browser, the client, the OAuth2 server, and your consent screen](/images/docs/oauth-server/dark/diagram-authorization.avif) +{% /only_dark %} +{% only_light %} +![The authorization code flow between the browser, the client, the OAuth2 server, and your consent screen](/images/docs/oauth-server/diagram-authorization.avif) +{% /only_light %} + +1. The client sends the user to the **authorization endpoint** with its client ID, a registered redirect URI, `response_type=code`, and the scopes it wants. +2. Appwrite checks whether the user has a session on your project. If they are signed in, Appwrite creates a pending authorization request called a **grant**. The grant connects the user, client, requested scopes, and redirect URI. +3. Appwrite sends the browser to your **authorization URL**, which hosts your consent screen. The URL contains the grant ID for a signed-in user or the original authorization parameters for a signed-out user. +4. Your consent screen signs the user in when needed, loads the grant, and lets the user approve or reject it. +5. On approval, Appwrite redirects the browser to the client's redirect URI with a short-lived authorization `code`. +6. The client exchanges the code for tokens at the token endpoint. See [Tokens](/docs/partners/oauth-server/tokens). + +# PKCE {% #pkce %} + +PKCE (Proof Key for Code Exchange) binds an authorization request to the client that started it. If someone intercepts the authorization code, they cannot exchange it without the original `code_verifier`. + +The client creates a random `code_verifier`, hashes it with SHA-256 to produce a `code_challenge`, and sends both `code_challenge` and `code_challenge_method=S256` in the authorization request. Appwrite supports `S256` only. + +PKCE is always required for **public clients**, which cannot safely keep a client secret. **Confidential clients** authenticate with a client secret and do not require PKCE by default. To require both protections for confidential clients, enable **Require PKCE** in your OAuth2 server settings. This setting is represented as `confidentialPkce` in the API. + +# 1. Send the user to the authorization endpoint {% #authorize %} + +The authorization endpoint is an Appwrite Cloud URL that the client opens in the user's browser. The client does not need an Appwrite SDK. + +The request contains: + +- `client_id`, which identifies the client asking for access. +- `redirect_uri`, which tells Appwrite where to return the browser after the user decides. It must match a URI registered for the client. +- `response_type=code`, which asks Appwrite to return an authorization code. The client exchanges this code for tokens later. +- `scope`, which lists the access the client is requesting. +- `state`, which the client uses to connect the callback to the request it started and protect against request forgery. +- `code_challenge` and `code_challenge_method=S256`, which are required for public clients and for confidential clients when **Require PKCE** is enabled. + +{% multicode %} +```curl +curl -G 'https://.cloud.appwrite.io/v1/oauth2//authorize' \ + --data-urlencode 'client_id=' \ + --data-urlencode 'redirect_uri=https://client.example.com/callback' \ + --data-urlencode 'response_type=code' \ + --data-urlencode 'scope=openid profile' \ + --data-urlencode 'state=' \ + --data-urlencode 'code_challenge=' \ + --data-urlencode 'code_challenge_method=S256' +``` +```hurl +GET https://.cloud.appwrite.io/v1/oauth2//authorize +[Query] +client_id: +redirect_uri: https://client.example.com/callback +response_type: code +scope: openid profile +state: +code_challenge: +code_challenge_method: S256 +``` +{% /multicode %} + +After Appwrite validates the request, the browser goes to the authorization URL configured for your project. Your consent screen handles the user's session and decision. + +## Pushed authorization requests {% #par %} + +Pushed authorization requests (PAR) keep the authorization parameters out of the browser URL. The client first sends the parameters to the PAR endpoint: + +{% multicode %} +```curl +curl -X POST 'https://.cloud.appwrite.io/v1/oauth2//par' \ + -H 'Content-Type: application/json' \ + -d '{ + "client_id": "", + "redirect_uri": "https://client.example.com/callback", + "response_type": "code", + "scope": "openid profile", + "state": "", + "code_challenge": "", + "code_challenge_method": "S256" + }' +``` +```hurl +POST https://.cloud.appwrite.io/v1/oauth2//par +Content-Type: application/json +{ + "client_id": "", + "redirect_uri": "https://client.example.com/callback", + "response_type": "code", + "scope": "openid profile", + "state": "", + "code_challenge": "", + "code_challenge_method": "S256" +} +``` +{% /multicode %} + +Appwrite returns a `request_uri` and the number of seconds before it expires: + +```json +{ + "request_uri": "urn:appwrite:oauth2:request:", + "expires_in": 600 +} +``` + +Before it expires, send the user to the authorization endpoint with only the `request_uri`. Do not repeat the original authorization parameters. + +{% multicode %} +```curl +curl -G 'https://.cloud.appwrite.io/v1/oauth2//authorize' \ + --data-urlencode 'request_uri=' +``` +```hurl +GET https://.cloud.appwrite.io/v1/oauth2//authorize +[Query] +request_uri: +``` +{% /multicode %} + +# 2. Host the consent screen {% #consent %} + +The authorization URL is a page you host. It must make sure the user is signed in, load the pending grant, explain what the client is requesting, and record the user's decision. + +The examples in this section use the generated client SDKs. The authorization URL is normally a browser page, but the same grant operations are available across the client SDKs. + +## Find or create the grant {% #resolve-grant %} + +A grant is Appwrite's record of one pending authorization request. It identifies the signed-in user and client, and stores the requested scopes, resources, and redirect URI. The `grant_id` in the consent page URL identifies the record your page needs to load. + +Handle the incoming URL in this order: + +1. Look for `grant_id` in the URL. If it is present, save the value and pass it to `oauth2.getGrant()` in [Load the request](#load-grant). The returned grant tells the consent screen which client is requesting access and which scopes and resources to show the user. +2. If `grant_id` is missing, call `account.get()` to check whether the user has a project session. +3. If the user is signed out, build a return URL from the consent page's current path and query string. Send the user to your normal sign-in or sign-up page with that value in a `redirect` search parameter. +4. After authentication, read `redirect` and return the user to it. The original authorization parameters are now available alongside the user's session. + +### Create the grant after sign-in {% #create-grant-after-sign-in %} + +When the signed-in user returns without `grant_id`, call `oauth2.authorize()` with the original authorization parameters. Use the returned `grantId` to continue. If the SDK returns `redirectUrl` instead, send the user there. + +{% multicode %} +```client-web +import { Client, Oauth2 } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const oauth2 = new Oauth2(client); + +const result = await oauth2.authorize({ + clientId: '', + redirectUri: 'https://example.com', + responseType: 'code', + scope: '', // optional + state: '', // optional + nonce: '', // optional + codeChallenge: '', // optional + codeChallengeMethod: 'S256', // optional + prompt: '', // optional + maxAge: 0, // optional + authorizationDetails: '', // optional + resource: '' // optional +}); + +console.log(result); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Oauth2 oauth2 = Oauth2(client); + +Oauth2Authorize result = await oauth2.authorize( + clientId: '', + redirectUri: 'https://example.com', + responseType: 'code', + scope: '', // optional + state: '', // optional + nonce: '', // optional + codeChallenge: '', // optional + codeChallengeMethod: 'S256', // optional + prompt: '', // optional + maxAge: 0, // optional + authorizationDetails: '', // optional + resource: '', // optional +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let oauth2 = Oauth2(client) + +let oauth2Authorize = try await oauth2.authorize( + client_id: "", + redirect_uri: "https://example.com", + response_type: "code", + scope: "", // optional + state: "", // optional + nonce: "", // optional + code_challenge: "", // optional + code_challenge_method: "S256", // optional + prompt: "", // optional + max_age: 0, // optional + authorization_details: "", // optional + resource: "" // optional +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.authorize( + client_id = "", + redirect_uri = "https://example.com", + response_type = "code", + scope = "", // (optional) + state = "", // (optional) + nonce = "", // (optional) + code_challenge = "", // (optional) + code_challenge_method = "S256", // (optional) + prompt = "", // (optional) + max_age = 0, // (optional) + authorization_details = "", // (optional) + resource = "", // (optional) +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.authorize( + "", // client_id + "https://example.com", // redirect_uri + "code", // response_type + "", // scope (optional) + "", // state (optional) + "", // nonce (optional) + "", // code_challenge (optional) + "S256", // code_challenge_method (optional) + "", // prompt (optional) + 0, // max_age (optional) + "", // authorization_details (optional) + "", // resource (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Oauth2 } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const oauth2 = new Oauth2(client); + +const result = await oauth2.authorize({ + clientId: '', + redirectUri: 'https://example.com', + responseType: 'code', + scope: '', // optional + state: '', // optional + nonce: '', // optional + codeChallenge: '', // optional + codeChallengeMethod: 'S256', // optional + prompt: '', // optional + maxAge: 0, // optional + authorizationDetails: '', // optional + resource: '' // optional +}); + +console.log(result); +``` +{% /multicode %} + +For a PAR request, send the browser back to the authorization endpoint with only its `request_uri`, as shown in [Pushed authorization requests](#par). + +## Load the request {% #load-grant %} + +Pass the grant ID to `oauth2.getGrant()`. Use the returned client details, scopes, and resources to explain the request on the consent screen. Keep the same grant ID for the approve or reject call after the user decides. + +{% multicode %} +```client-web +import { Client, Oauth2 } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const oauth2 = new Oauth2(client); + +const result = await oauth2.getGrant({ + grantId: '' +}); + +console.log(result); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Oauth2 oauth2 = Oauth2(client); + +Oauth2Grant result = await oauth2.getGrant( + grantId: '', +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let oauth2 = Oauth2(client) + +let oauth2Grant = try await oauth2.getGrant( + grant_id: "" +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.getGrant( + grant_id = "", +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.getGrant( + "", // grant_id + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Oauth2 } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const oauth2 = new Oauth2(client); + +const result = await oauth2.getGrant({ + grantId: '' +}); + +console.log(result); +``` +{% /multicode %} + +## Record the user's decision {% #decision %} + +When the user approves the request, you can pass the scopes they kept selected. Omit `scope` to approve every scope in the grant. + +{% multicode %} +```client-web +import { Client, Oauth2 } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const oauth2 = new Oauth2(client); + +const result = await oauth2.approve({ + grantId: '', + authorizationDetails: '', // optional + scope: '' // optional +}); + +console.log(result); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Oauth2 oauth2 = Oauth2(client); + +Oauth2Approve result = await oauth2.approve( + grantId: '', + authorizationDetails: '', // optional + scope: '', // optional +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let oauth2 = Oauth2(client) + +let oauth2Approve = try await oauth2.approve( + grant_id: "", + authorization_details: "", // optional + scope: "" // optional +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.approve( + grant_id = "", + authorization_details = "", // (optional) + scope = "", // (optional) +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.approve( + "", // grant_id + "", // authorization_details (optional) + "", // scope (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Oauth2 } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const oauth2 = new Oauth2(client); + +const result = await oauth2.approve({ + grantId: '', + authorizationDetails: '', // optional + scope: '' // optional +}); + +console.log(result); +``` +{% /multicode %} + +The SDK response contains `redirectUrl`. Send the user to that URL to return them to the client. + +If the user declines, reject the grant and return them to the client with an `access_denied` error. + +{% multicode %} +```client-web +import { Client, Oauth2 } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const oauth2 = new Oauth2(client); + +const result = await oauth2.reject({ + grantId: '' +}); + +console.log(result); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Oauth2 oauth2 = Oauth2(client); + +Oauth2Reject result = await oauth2.reject( + grantId: '', +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let oauth2 = Oauth2(client) + +let oauth2Reject = try await oauth2.reject( + grant_id: "" +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.reject( + grant_id = "", +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.reject( + "", // grant_id + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Oauth2 } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const oauth2 = new Oauth2(client); + +const result = await oauth2.reject({ + grantId: '' +}); + +console.log(result); +``` +{% /multicode %} + +The SDK requests JSON responses so it can behave consistently with other SDK methods. The approve and reject methods therefore return `redirectUrl`, and your browser code must navigate to it. A direct HTTP request without an `Accept: application/json` header receives a `302` redirect automatically. + +## Consent screen best practices {% #consent-best-practices %} + +- Show the client's name and logo so the user can recognize who is asking for access. +- Name the client in the approval action, such as **Allow Vantage**. +- Explain each scope in plain language instead of showing only its identifier. +- Let the user turn off optional scopes. Pass the remaining scopes to `oauth2.approve()`. +- Call attention to scopes that allow writing, deleting, broad access, or administrator-level access. +- Give the reject action clear, visible placement. Consent should be a real choice. +- Do not ask the user for their Appwrite password on the consent screen. Send signed-out users through your normal authentication flow. + +# Device flow authorization {% #device-flow %} + +Device flow is used when the client cannot easily open a browser or accept a callback, such as a TV or command-line tool. The client starts device authorization and shows the user a verification URL and user code. See [Device flow](/docs/partners/oauth-server/device-flow) for the client requests and polling behavior. + +Your verification page on the user's second device completes the authorization: + +1. Read `user_code` from the URL. If it is missing, let the user enter the code shown on the original device. +2. Ask the user to confirm that the code matches the one on the original device. +3. Make sure the user is signed in. If not, use the same `redirect` pattern as the consent screen so the user returns with the code intact. +4. Call `oauth2.createGrant({ userCode })`. This connects the pending device request to the signed-in user and returns its grant record, including the client and requested access to show on the verification page. +5. Pass the returned grant's ID to `oauth2.approve()` or `oauth2.reject()` after the user decides. + +The original device continues polling the token endpoint at the response's `interval`. After the user approves the grant, the device receives access and refresh tokens directly. Device flow does not use an authorization code callback. + +# Redirect URI matching {% #redirect-uris %} + +The `redirect_uri` on an authorization request must exactly match one of the client's registered redirect URIs, character for character. This stops an attacker from redirecting a code to a URL they control. + +There is one narrow exception. For **public** clients, an `http` loopback address (`localhost`, `127.0.0.1`, or `[::1]`) matches on everything except the port. Native and CLI apps bind an unpredictable local port at runtime and cannot register it ahead of time (RFC 8252). Confidential clients get no such exception. Their redirect URIs must match exactly, including the port. diff --git a/src/routes/docs/partners/oauth-server/clients/+page.markdoc b/src/routes/docs/partners/oauth-server/clients/+page.markdoc new file mode 100644 index 0000000000..a23c43be9d --- /dev/null +++ b/src/routes/docs/partners/oauth-server/clients/+page.markdoc @@ -0,0 +1,1321 @@ +--- +layout: article +title: Clients +description: Register confidential and public OAuth clients against your Appwrite project's OAuth2 server and manage them from your own developer platform. +back: /docs/partners/oauth-server +--- + +A **client** is a third-party app that authenticates users through your project's OAuth2 server. Each client registers the redirect URIs it is allowed to return to and the post-logout redirect URIs it can end sessions at, sets its type, and chooses whether the [device flow](/docs/partners/oauth-server/device-flow) is enabled. Its other attributes serve two surfaces: branding like the name, logo, and tagline can appear on your consent screen, while attributes like tags, images, and the privacy policy URL are for your project's apps marketplace. + +Integrators never visit the Appwrite Console. You are expected to build a developer platform on your own website with the [Client SDKs](/docs/sdks#client)' `apps` service, which covers the full lifecycle: `create`, `update`, `get`, `list`, `delete`, along with `createSecret`, `listSecrets`, `getSecret`, `deleteSecret`, `updateTeam`, and `deleteTokens`. Any signed-in user on your project can call them; no API key is involved. The Console's **Auth > OAuth2 server > Apps** tab is your own administrative view of the same data. + +{% only_dark %} +![OAuth2 clients list in the Appwrite Console](/images/docs/oauth-server/dark/oauth2-server-apps-list.avif) +{% /only_dark %} +{% only_light %} +![OAuth2 clients list in the Appwrite Console](/images/docs/oauth-server/oauth2-server-apps-list.avif) +{% /only_light %} + +Server SDKs matter in exactly two places: [introspecting](/docs/partners/oauth-server/tokens#introspect) a public client's tokens, which needs an API key because there is no client secret, and administrative operations like [curating labels](#labels). + +# Confidential and public clients {% #client-types %} + +{% only_dark %} +![Confidential clients hold a secret on a server while public clients rely on PKCE](/images/docs/oauth-server/dark/diagram-clients.avif) +{% /only_dark %} +{% only_light %} +![Confidential clients hold a secret on a server while public clients rely on PKCE](/images/docs/oauth-server/diagram-clients.avif) +{% /only_light %} + +Every client is one of two types, and the difference comes down to a single question: can the app keep a secret? + +- A **confidential** client runs code on a server the developer controls, so it can store a `client_secret` that users never see. It authenticates to the token endpoint with that secret, which lets your server prove which client is calling. +- A **public** client runs entirely on the user's device (a single-page app, a native mobile app, a CLI), where any embedded secret would ship to the user and could be read. Public clients receive no secret and rely on PKCE instead. + +{% only_dark %} +![Token lifetimes for confidential and public clients](/images/docs/oauth-server/dark/oauth2-server-token-lifetimes.avif) +{% /only_dark %} +{% only_light %} +![Token lifetimes for confidential and public clients](/images/docs/oauth-server/oauth2-server-token-lifetimes.avif) +{% /only_light %} + +The type a client uses changes what it can do: + +| | Confidential | Public | +| --- | --- | --- | +| Client secret | Issued, sent on token requests | None issued | +| PKCE | Optional (configurable per project) | Always required | +| Token introspection | With its client secret | With a project API key holding the `oauth2.read` scope | +| Default access token lifetime | 8 hours | 1 hour | +| Default refresh token lifetime | 365 days | 30 days | + +Choose confidential whenever the app has a backend. It is the safer default: the token exchange is protected by a secret, tokens never touch the browser, and sessions can last longer. Reserve public for apps that genuinely have no server to hold a secret. + +# Register a client {% #register %} + +Create a client with the `create` method. It needs only a name and a redirect URI; everything else can be filled in later with `update`. Registering a confidential client also calls for a [secret](#secrets) before it can exchange tokens. + +{% multicode %} +```client-web +import { Client, Apps, ID } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const app = await apps.create({ + appId: ID.unique(), + name: 'Custom App', + redirectUris: ['http://vantage.localhost/auth/redirect'], +}); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Apps apps = Apps(client); + +App app = await apps.create( + appId: ID.unique(), + name: 'Custom App', + redirectUris: ['http://vantage.localhost/auth/redirect'], +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let apps = Apps(client) + +let app = try await apps.create( + appId: ID.unique(), + name: "Custom App", + redirectUris: ["http://vantage.localhost/auth/redirect"] +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.ID +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val app = apps.create( + appId = ID.unique(), + name = "Custom App", + redirectUris = listOf("http://vantage.localhost/auth/redirect") +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.ID; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.create( + ID.unique(), // appId + "Custom App", // name + List.of("http://vantage.localhost/auth/redirect"), // redirectUris + null, // description (optional) + null, // clientUri (optional) + null, // logoUri (optional) + null, // privacyPolicyUrl (optional) + null, // termsUrl (optional) + null, // contacts (optional) + null, // tagline (optional) + null, // tags (optional) + null, // images (optional) + null, // supportUrl (optional) + null, // dataDeletionUrl (optional) + null, // postLogoutRedirectUris (optional) + null, // enabled (optional) + null, // type (optional) + null, // deviceFlow (optional) + null, // teamId (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Apps, ID } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const app = await apps.create({ + appId: ID.unique(), + name: 'Custom App', + redirectUris: ['http://vantage.localhost/auth/redirect'], +}); +``` +{% /multicode %} + +An app is owned by the user who created it. For team-oriented platforms, pass `teamId` on creation to make it team-owned instead: every team member can see it, while members with the `owner` or `developer` role manage it. + +You can also create clients from the Console's **Apps** tab, which offers the same fields. + +{% only_dark %} +![Create an OAuth2 client dialog](/images/docs/oauth-server/dark/oauth2-server-create-app.avif) +{% /only_dark %} +{% only_light %} +![Create an OAuth2 client dialog](/images/docs/oauth-server/oauth2-server-create-app.avif) +{% /only_light %} + +{% info title="Use Storage for logos and images" %} +`logoUri` and `images` accept URLs, not files. [Appwrite Storage](/docs/products/storage) pairs well here: upload the file to a bucket, take its preview URL, and store that URL on the app. +{% /info %} + +# Manage client secrets {% #secrets %} + +A confidential client authenticates with a secret. Four methods manage them in one place: `createSecret`, `listSecrets`, `getSecret`, and `deleteSecret`. + +Generate a new secret with `createSecret`. The plaintext value is returned only in this response. + +{% only_dark %} +![OAuth2 client secret shown once on creation](/images/docs/oauth-server/dark/oauth2-server-secret-created.avif) +{% /only_dark %} +{% only_light %} +![OAuth2 client secret shown once on creation](/images/docs/oauth-server/oauth2-server-secret-created.avif) +{% /only_light %} + +{% multicode %} +```client-web +import { Client, Apps } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const secret = await apps.createSecret({ + appId: '', +}); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Apps apps = Apps(client); + +AppSecretPlaintext secret = await apps.createSecret( + appId: '', +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let apps = Apps(client) + +let secret = try await apps.createSecret( + appId: "" +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val secret = apps.createSecret( + appId = "" +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.createSecret( + "", // appId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Apps } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const secret = await apps.createSecret({ + appId: '', +}); +``` +{% /multicode %} + +A client can hold several secrets at once, which is how you rotate them without downtime: create the new secret, roll it out, then delete the old one. Each entry in `listSecrets` carries the metadata for deciding which secret can be removed safely: a `hint` of the value, who created it (`createdById`, `createdByName`), when it was created, and `lastAccessedAt` for when it last authenticated a request. + +{% multicode %} +```client-web +import { Client, Apps } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const secrets = await apps.listSecrets({ + appId: '', +}); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Apps apps = Apps(client); + +AppSecretList secrets = await apps.listSecrets( + appId: '', +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let apps = Apps(client) + +let secrets = try await apps.listSecrets( + appId: "" +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val secrets = apps.listSecrets( + appId = "" +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.listSecrets( + "", // appId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Apps } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const secrets = await apps.listSecrets({ + appId: '', +}); +``` +{% /multicode %} + +`getSecret` reads a single entry by ID, and `deleteSecret` revokes it immediately. + +# List clients {% #list %} + +The `list` method drives three different screens, depending on the queries you pass: + +- **A developer portal**: filter by the signed-in user with `Query.equal('userId', userId)` so developers manage their own apps. +- **Team settings**: filter with `Query.equal('teamId', teamId)` for the apps a team owns. +- **An apps marketplace**: list without an owner filter to show all registered apps. Filter by [labels](#labels), such as `Query.contains('labels', ['official'])`, when the marketplace should only show apps you have vetted, because labels cannot be self-assigned. + +Always paginate with `Query.limit()` and `Query.cursorAfter()`; a marketplace can grow past any single page. + +{% multicode %} +```client-web +import { Client, Apps, Query } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const portal = await apps.list({ + queries: [ + Query.equal('userId', ''), + Query.limit(25), + ], +}); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Apps apps = Apps(client); + +AppList portal = await apps.list( + queries: [ + Query.equal('userId', ''), + Query.limit(25), + ], +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let apps = Apps(client) + +let portal = try await apps.list( + queries: [ + Query.equal("userId", value: ""), + Query.limit(25) + ] +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.Query +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val portal = apps.list( + queries = listOf( + Query.equal("userId", ""), + Query.limit(25) + ) +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.Query; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.list( + List.of( + Query.Companion.equal("userId", ""), + Query.Companion.limit(25) + ), // queries (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Apps, Query } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const portal = await apps.list({ + queries: [ + Query.equal('userId', ''), + Query.limit(25), + ], +}); +``` +{% /multicode %} + +# Get a client {% #get %} + +Read a single client with `get`. This backs the app detail page in a developer portal, and a [consent screen](/docs/partners/oauth-server/authorization#consent) uses it to show the requesting app's name and logo. + +{% multicode %} +```client-web +import { Client, Apps } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const app = await apps.get({ + appId: '', +}); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Apps apps = Apps(client); + +App app = await apps.get( + appId: '', +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let apps = Apps(client) + +let app = try await apps.get( + appId: "" +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val app = apps.get( + appId = "" +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.get( + "", // appId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Apps } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const app = await apps.get({ + appId: '', +}); +``` +{% /multicode %} + +# Update a client {% #update %} + +Change a client's redirect URIs, branding, or type with the `update` method. + +{% multicode %} +```client-web +import { Client, Apps } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const app = await apps.update({ + appId: '', + name: 'Custom App', + description: 'A third-party app that signs in with your product.', + logoUri: 'https://example.com/logo.png', + redirectUris: ['http://vantage.localhost/auth/redirect'], + enabled: true, +}); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Apps apps = Apps(client); + +App app = await apps.update( + appId: '', + name: 'Custom App', + description: 'A third-party app that signs in with your product.', + logoUri: 'https://example.com/logo.png', + redirectUris: ['http://vantage.localhost/auth/redirect'], + enabled: true, +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let apps = Apps(client) + +let app = try await apps.update( + appId: "", + name: "Custom App", + description: "A third-party app that signs in with your product.", + logoUri: "https://example.com/logo.png", + redirectUris: ["http://vantage.localhost/auth/redirect"], + enabled: true +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val app = apps.update( + appId = "", + name = "Custom App", + description = "A third-party app that signs in with your product.", + logoUri = "https://example.com/logo.png", + redirectUris = listOf("http://vantage.localhost/auth/redirect"), + enabled = true +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.update( + "", // appId + "Custom App", // name + "A third-party app that signs in with your product.", // description (optional) + null, // clientUri (optional) + "https://example.com/logo.png", // logoUri (optional) + null, // privacyPolicyUrl (optional) + null, // termsUrl (optional) + null, // contacts (optional) + null, // tagline (optional) + null, // tags (optional) + null, // images (optional) + null, // supportUrl (optional) + null, // dataDeletionUrl (optional) + true, // enabled (optional) + List.of("http://vantage.localhost/auth/redirect"), // redirectUris (optional) + null, // postLogoutRedirectUris (optional) + null, // type (optional) + null, // deviceFlow (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Apps } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const app = await apps.update({ + appId: '', + name: 'Custom App', + description: 'A third-party app that signs in with your product.', + logoUri: 'https://example.com/logo.png', + redirectUris: ['http://vantage.localhost/auth/redirect'], + enabled: true, +}); +``` +{% /multicode %} + +The `type` parameter accepts `confidential` (the default) or `public`. Set `deviceFlow` to `true` to let the client use the [device authorization flow](/docs/partners/oauth-server/device-flow). The branding fields (`logoUri`, `tagline`, `privacyPolicyUrl`, `termsUrl`) can appear on the consent screen, and they fill out the app's listing on your marketplace. + +# Transfer to a team {% #transfer %} + +Convert a user-owned app to team ownership, or move it between teams, with `updateTeam`. The member doing the transfer needs the `owner` or `developer` role in the app's current team, and at least membership in the new one. + +{% multicode %} +```client-web +import { Client, Apps } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const app = await apps.updateTeam({ + appId: '', + teamId: '', +}); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Apps apps = Apps(client); + +App app = await apps.updateTeam( + appId: '', + teamId: '', +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let apps = Apps(client) + +let app = try await apps.updateTeam( + appId: "", + teamId: "" +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val app = apps.updateTeam( + appId = "", + teamId = "" +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.updateTeam( + "", // appId + "", // teamId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Apps } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const app = await apps.updateTeam({ + appId: '', + teamId: '', +}); +``` +{% /multicode %} + +# Curate with labels {% #labels %} + +Labels are trust markers like `official`, `partner`, or `verified`. They are read-only for clients: only a [Server SDK](/docs/sdks#server) using a project API key with the `apps.write` scope can set them, so app owners cannot mark themselves as trusted. That is what makes them safe to filter a marketplace by. + +{% multicode %} +```server-nodejs +import { Client, Apps } from 'node-appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject('') // Your project ID + .setKey(''); // Your secret API key + +const apps = new Apps(client); + +const app = await apps.updateLabels({ + appId: '', + labels: ['official'], +}); +``` +```server-deno +import { Client, Apps } from "npm:node-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject('') // Your project ID + .setKey(''); // Your secret API key + +const apps = new Apps(client); + +const app = await apps.updateLabels({ + appId: '', + labels: ['official'], +}); +``` +```server-php +setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + ->setProject('') // Your project ID + ->setKey(''); // Your secret API key + +$apps = new Apps($client); + +$app = $apps->updateLabels( + appId: '', + labels: ['official'] +); +``` +```server-python +from appwrite.client import Client +from appwrite.services.apps import Apps + +client = Client() +client.set_endpoint('https://.cloud.appwrite.io/v1') # Your API Endpoint +client.set_project('') # Your project ID +client.set_key('') # Your secret API key + +apps = Apps(client) + +app = apps.update_labels( + app_id = '', + labels = ['official'] +) +``` +```server-ruby +require 'appwrite' + +include Appwrite + +client = Client.new + .set_endpoint('https://.cloud.appwrite.io/v1') # Your API Endpoint + .set_project('') # Your project ID + .set_key('') # Your secret API key + +apps = Apps.new(client) + +app = apps.update_labels( + app_id: '', + labels: ['official'] +) +``` +```server-dart +import 'package:dart_appwrite/dart_appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject('') // Your project ID + .setKey(''); // Your secret API key + +Apps apps = Apps(client); + +App app = await apps.updateLabels( + appId: '', + labels: ['official'], +); +``` +```server-dotnet +using Appwrite; +using Appwrite.Models; +using Appwrite.Services; + +Client client = new Client() + .SetEndPoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .SetProject("") // Your project ID + .SetKey(""); // Your secret API key + +Apps apps = new Apps(client); + +App app = await apps.UpdateLabels( + appId: "", + labels: new List { "official" } +); +``` +```server-kotlin +import io.appwrite.Client +import io.appwrite.services.Apps + +val client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + .setKey("") // Your secret API key + +val apps = Apps(client) + +val app = apps.updateLabels( + appId = "", + labels = listOf("official") +) +``` +```server-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + .setKey(""); // Your secret API key + +Apps apps = new Apps(client); + +apps.updateLabels( + "", // appId + List.of("official"), // labels + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + System.out.println(result); + }) +); +``` +```server-swift +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + .setKey("") // Your secret API key + +let apps = Apps(client) + +let app = try await apps.updateLabels( + appId: "", + labels: ["official"] +) +``` +```server-go +package main + +import ( + "fmt" + "github.com/appwrite/sdk-for-go/apps" + "github.com/appwrite/sdk-for-go/client" +) + +func main() { + c := client.New( + client.WithEndpoint("https://.cloud.appwrite.io/v1"), + client.WithProject(""), + client.WithKey(""), + ) + + service := apps.New(c) + + app, err := service.UpdateLabels( + "", + []string{"official"}, + ) + if err != nil { + panic(err) + } + + fmt.Println(app) +} +``` +```server-rust +use appwrite::client::Client; +use appwrite::services::apps::Apps; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = Client::new() + .set_endpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .set_project("") // Your project ID + .set_key(""); // Your secret API key + + let apps = Apps::new(&client); + + let app = apps + .update_labels( + "", + vec!["official".into()], + ) + .await?; + + let _ = app; + + Ok(()) +} +``` +{% /multicode %} + +Labels replace the previous set on every call. Up to 100 labels are allowed, each up to 36 alphanumeric characters. + +# Revoke all tokens {% #revoke-tokens %} + +`deleteTokens` invalidates every token issued to a client at once: a kill switch for all of its sessions. Reach for it when testing, since it forces the consent screen to reappear, or as an emergency response to a leaked secret, together with rotating the secret itself. + +{% multicode %} +```client-web +import { Client, Apps } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +await apps.deleteTokens({ + appId: '', +}); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Apps apps = Apps(client); + +await apps.deleteTokens( + appId: '', +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let apps = Apps(client) + +try await apps.deleteTokens( + appId: "" +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +apps.deleteTokens( + appId = "" +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.deleteTokens( + "", // appId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Apps } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +await apps.deleteTokens({ + appId: '', +}); +``` +{% /multicode %} + +# Delete a client {% #delete %} + +Deleting a client immediately invalidates every token issued to it. + +{% multicode %} +```client-web +import { Client, Apps } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +await apps.delete({ + appId: '', +}); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Apps apps = Apps(client); + +await apps.delete( + appId: '', +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let apps = Apps(client) + +try await apps.delete( + appId: "" +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +apps.delete( + appId = "" +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.delete( + "", // appId + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Apps } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +await apps.delete({ + appId: '', +}); +``` +{% /multicode %} + +# Dynamic client registration {% #dynamic-registration %} + +Clients can also register themselves over plain HTTP, without an Appwrite SDK or a signed-in user, through the registration endpoint ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)). This is what makes your OAuth2 server compatible with MCP servers and other software that provisions its own client on first contact. Registration is rate-limited per IP. + +{% multicode %} +```curl +curl -X POST 'https://.cloud.appwrite.io/v1/oauth2//register' \ + -H 'Content-Type: application/json' \ + -d '{ + "client_name": "MCP Client", + "redirect_uris": [ + "http://vantage.localhost/auth/redirect" + ], + "token_endpoint_auth_method": "none" + }' +``` +```hurl +POST https://.cloud.appwrite.io/v1/oauth2//register +Content-Type: application/json +{ + "client_name": "MCP Client", + "redirect_uris": [ + "http://vantage.localhost/auth/redirect" + ], + "token_endpoint_auth_method": "none" +} +``` +{% /multicode %} + +```json +{ + "client_id": "6a56677caf736a2310a2", + "client_id_issued_at": 1784047484, + "redirect_uris": ["http://vantage.localhost/auth/redirect"], + "token_endpoint_auth_method": "none", + "grant_types": ["authorization_code"], + "response_types": ["code"], + "client_name": "MCP Client" +} +``` + +`token_endpoint_auth_method: none` registers a public client for PKCE; `client_secret_basic` (the default) and `client_secret_post` register confidential clients. The registered app appears in your Console and in `list` like any other client. diff --git a/src/routes/docs/partners/oauth-server/custom-scopes/+layout.svelte b/src/routes/docs/partners/oauth-server/custom-scopes/+layout.svelte new file mode 100644 index 0000000000..c1c9c5a8cb --- /dev/null +++ b/src/routes/docs/partners/oauth-server/custom-scopes/+layout.svelte @@ -0,0 +1,10 @@ + + +{@render children()} diff --git a/src/routes/docs/partners/oauth-server/custom-scopes/+layout.ts b/src/routes/docs/partners/oauth-server/custom-scopes/+layout.ts new file mode 100644 index 0000000000..c8884f46b8 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/custom-scopes/+layout.ts @@ -0,0 +1,11 @@ +import type { LayoutLoad } from './$types'; + +export const load: LayoutLoad = ({ url }) => { + const tutorials = import.meta.glob('./**/*.markdoc', { + eager: true + }); + return { + tutorials, + pathname: url.pathname + }; +}; diff --git a/src/routes/docs/partners/oauth-server/custom-scopes/+page.ts b/src/routes/docs/partners/oauth-server/custom-scopes/+page.ts new file mode 100644 index 0000000000..33146aab15 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/custom-scopes/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from '@sveltejs/kit'; + +export function load() { + redirect(303, '/docs/partners/oauth-server/custom-scopes/step-1'); +} diff --git a/src/routes/docs/partners/oauth-server/custom-scopes/step-1/+page.markdoc b/src/routes/docs/partners/oauth-server/custom-scopes/step-1/+page.markdoc new file mode 100644 index 0000000000..578b28a6ea --- /dev/null +++ b/src/routes/docs/partners/oauth-server/custom-scopes/step-1/+page.markdoc @@ -0,0 +1,45 @@ +--- +layout: tutorial +title: Protect your API with custom scopes +description: Define custom scopes on your Appwrite OAuth2 server, request them from a client, and enforce them on your own API. +step: 1 +difficulty: intermediate +readtime: 20 +framework: TanStack Start +category: OAuth2 server +back: /docs/partners/oauth-server +--- + +The [Sign in with your product](/docs/partners/oauth-server/sign-in-with-your-product/step-1) guide gave Vantage the user's identity. Identity alone only answers who the user is. To let an integration read the user's data from your product, you need [custom scopes](/docs/partners/oauth-server/scopes): permissions you define, users approve, and your API enforces. + +This tutorial continues with the same two apps. TaskFlow gains a task API that checks scopes, and Vantage asks for permission to read the user's tasks and shows them on its dashboard. + +# What you will build {% #what-you-will-build %} + +- Two custom scopes on TaskFlow's OAuth2 server: `tasks.read` and `tasks.write`. +- A consent screen where the user grants or withholds **each permission individually**. +- A **resource server**: an API route on TaskFlow that validates access tokens and enforces the scopes they carry, operation by operation. +- A task composer on Vantage's dashboard that succeeds or gets refused depending on what the user granted. + +The resource server is the new idea. The OAuth2 server issues tokens and stamps the granted scopes onto them, but it does not know what `tasks.read` means in your product. Your API gives the scope its meaning by checking it on every request. + +# The flow {% #the-flow %} + +1. Vantage adds `tasks.read` and `tasks.write` to its authorization request. +2. TaskFlow's consent screen shows each permission, and the user decides which to grant. +3. The access token Vantage receives carries exactly the approved scopes in its `scope` claim. +4. Vantage calls TaskFlow's task API with the access token. +5. TaskFlow verifies the token's signature, checks the scope the operation needs, and allows or refuses. + +Everything up to step 3 is the authorization code flow from the first guide, with more scopes in the request. Steps 4 and 5 are what you build here. + +# Prerequisites {% #prerequisites %} + +- The two apps from [Sign in with your product](/docs/partners/oauth-server/sign-in-with-your-product/step-1), running against a project with the OAuth2 server enabled and Vantage registered as a confidential client. +- [Node.js](https://nodejs.org/) 20 or newer and `pnpm`. + +{% info title="Get the finished code" %} +The complete apps from this tutorial are on GitHub at [appwrite-community/oauth-guide-custom-scopes](https://github.com/appwrite-community/oauth-guide-custom-scopes). Clone it to follow along or to compare against your own. +{% /info %} + +Continue to define the scopes on your project. diff --git a/src/routes/docs/partners/oauth-server/custom-scopes/step-2/+page.markdoc b/src/routes/docs/partners/oauth-server/custom-scopes/step-2/+page.markdoc new file mode 100644 index 0000000000..5a7c2c7891 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/custom-scopes/step-2/+page.markdoc @@ -0,0 +1,32 @@ +--- +layout: tutorial +title: Define the scopes +description: Add tasks.read and tasks.write to your OAuth2 server's scopes. +step: 2 +--- + +Scopes have to be defined on the OAuth2 server before a client can request them. Requesting a scope you have not defined fails the authorization request with `invalid_scope`. + +# Add the scopes {% #add %} + +{% only_dark %} +![The tasks.read and tasks.write scopes on the OAuth2 server settings](/images/docs/oauth-server/dark/scopes-defined.avif) +{% /only_dark %} +{% only_light %} +![The tasks.read and tasks.write scopes on the OAuth2 server settings](/images/docs/oauth-server/scopes-defined.avif) +{% /only_light %} + +In the Console, open **Auth**, select the **OAuth2 server** tab, and find the **Scopes** field on the **Integration** card. Add two scopes and click **Update**: + +- `tasks.read` grants read access to the user's tasks. +- `tasks.write` grants permission to create and update tasks. + +The `openid`, `profile`, `email`, and `phone` scopes stay locked in place. They are always available, so you only define the ones specific to your product. + +# Naming scopes {% #naming %} + +The `verb:resource` shape is a convention, not a requirement. Any string up to 128 characters works. Splitting read and write per resource keeps grants small: an integration that only shows data never has to ask for permission to change it, which is an easier ask on the consent screen. + +Both scopes are now published in your project's [discovery document](/docs/partners/oauth-server/quick-start#discovery) under `scopes_supported`, so integrators can see them without asking you. + +Continue to request the new scopes from Vantage. diff --git a/src/routes/docs/partners/oauth-server/custom-scopes/step-3/+page.markdoc b/src/routes/docs/partners/oauth-server/custom-scopes/step-3/+page.markdoc new file mode 100644 index 0000000000..43cb397a4d --- /dev/null +++ b/src/routes/docs/partners/oauth-server/custom-scopes/step-3/+page.markdoc @@ -0,0 +1,119 @@ +--- +layout: tutorial +title: Request the scopes +description: Ask for the task scopes during authorization and let the user grant each one individually. +step: 3 +--- + +A client receives a scope by asking for it during [authorization](/docs/partners/oauth-server/authorization). Vantage requests both task scopes, and TaskFlow's consent screen lets the user decide which of them to grant. + +# Add the scopes to the request {% #request %} + +In the consumer, extend the scope list in `consumer/src/lib/oauth.ts`: + +```ts +// consumer/src/lib/oauth.ts +// tasks.read and tasks.write are custom scopes defined on TaskFlow's +// OAuth2 server. They authorize Vantage against TaskFlow's task API. +export const SCOPES = 'openid profile email tasks.read tasks.write' +``` + +`authorizeUrl` already passes `SCOPES` as the `scope` parameter, so nothing else changes on the consumer. The OAuth2 server carries the requested scopes into the grant and shows them to the user. + +# Label the scopes on the consent screen {% #label %} + +TaskFlow's consent card maps scopes to human-readable lines through `SCOPE_LABELS`. Add labels for the new scopes in `provider/src/lib/consent-types.ts`: + +```ts +// provider/src/lib/consent-types.ts +// Human-readable labels for the scopes shown on the consent card. +// Custom scopes get labels too, so users understand what they grant. +export const SCOPE_LABELS: Record = { + openid: 'Confirm your identity', + profile: 'See your name and profile details', + email: 'See your email address', + phone: 'See your phone number', + 'tasks.read': 'See your tasks', + 'tasks.write': 'Create and update your tasks', +} +``` + +A raw scope string like `tasks.read` means something to you, not to your users. The label is what stands between them and approving a permission they do not understand. + +# Let the user choose {% #choose %} + +Consent is not all-or-nothing. When approving a grant, the consent screen can pass the subset of scopes the user agreed to, and the OAuth2 server narrows the grant to exactly that. It rejects any scope that was not requested, and `openid` is always retained because it is the sign-in itself. + +Update the `approve` helper in `provider/src/lib/oauth-server.ts` to pass the chosen scopes: + +```ts +// provider/src/lib/oauth-server.ts +/** Approve a grant. The OAuth2 server responds with the URL to send the + * user back to, carrying the authorization code. Passing scopes narrows + * the grant to that subset; the server rejects anything that was not + * requested and always retains openid. */ +export async function approve( + grantId: string, + sessionToken: string, + scopes?: string[], +): Promise { + const res = await fetch(`${issuer}/approve`, { + method: 'POST', + headers: { + ...projectHeaders, + 'X-Appwrite-Session': sessionToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + grant_id: grantId, + ...(scopes ? { scope: scopes.join(' ') } : {}), + }), + redirect: 'manual', + }) + const location = res.headers.get('location') + if (!location) throw new Error(`Approve failed: ${res.status}`) + return location +} +``` + +On the consent card in `provider/src/routes/oauth.consent.tsx`, track the user's selection and send it on approve. Each permission renders as a toggle, on by default, with `openid` locked: + +```tsx +// provider/src/routes/oauth.consent.tsx (selection state) +// The user decides scope by scope. openid is the sign-in itself, so it +// stays on; everything else starts granted and can be switched off. +const [selected, setSelected] = useState>( + () => new Set(grant.scopes), +) + +const toggle = (scope: string) => { + if (scope === 'openid') return + setSelected((prev) => { + const next = new Set(prev) + if (next.has(scope)) next.delete(scope) + else next.add(scope) + return next + }) +} +``` + +```tsx +// provider/src/routes/oauth.consent.tsx (approve with the selection) +const approveGrant = createServerFn({ method: 'POST' }) + .validator((d: { grantId: string; scopes: string[] }) => d) + .handler(async ({ data }) => { + const session = await taskflowSession() + const location = await approve(data.grantId, session.data.token!, data.scopes) + throw redirect({ href: location }) + }) +``` + +The card's list renders one row per scope in `grant.scopes`, calling `toggle` on click, and the **Authorize** button submits `Array.from(selected)`. The full component is in the [tutorial repository](https://github.com/appwrite-community/oauth-guide-custom-scopes). + +# What the user sees {% #consent %} + +![TaskFlow consent screen listing each requested permission as a toggle](/images/docs/oauth-server/scopes-guide/taskflow-consent-scopes.avif) + +When a user signs in to Vantage now, the consent card lists **See your tasks** and **Create and update your tasks** alongside the identity permissions, each one granted or withheld with a click. Users who approved Vantage before this change are asked again, because their earlier grant does not cover the new scopes. + +Continue to validate access tokens on TaskFlow's server. diff --git a/src/routes/docs/partners/oauth-server/custom-scopes/step-4/+page.markdoc b/src/routes/docs/partners/oauth-server/custom-scopes/step-4/+page.markdoc new file mode 100644 index 0000000000..ac6e1e1189 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/custom-scopes/step-4/+page.markdoc @@ -0,0 +1,74 @@ +--- +layout: tutorial +title: Validate access tokens +description: Verify incoming access tokens against your project's JWKS and read their scopes. +step: 4 +--- + +TaskFlow's API is about to accept access tokens from the outside world, so it first needs a way to tell a token it issued from one somebody made up. Access tokens from your OAuth2 server are RS256-signed JWTs, and the matching public keys are published at your project's JWKS endpoint. That means TaskFlow can verify tokens locally, with no call back to the OAuth2 server on each request. + +# Install jose {% #install %} + +[jose](https://github.com/panva/jose) handles the JWT verification and the JWKS fetching. Install it in the provider: + +```sh +cd provider +pnpm add jose +``` + +# The token guard {% #guard %} + +Create `provider/src/lib/resource-server.ts`: + +```ts +// provider/src/lib/resource-server.ts +// TaskFlow's resource server: validates access tokens issued by the +// project's OAuth2 server and enforces the scopes they carry. +import '@tanstack/react-start/server-only' +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose' + +const issuer = process.env.OAUTH_ISSUER! + +// The OAuth2 server publishes its signing keys as a JWK Set. jose caches +// the keys and refetches them when it sees an unknown key ID. +const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`)) + +export type AccessToken = JWTPayload & { + scope?: string + client_id: string +} + +/** Verify a Bearer token's signature against the JWKS and return its + * claims. Throws if the token is missing, expired, not ours, or not an + * access token. Requiring the at+jwt type (RFC 9068) is what stops an + * ID token, which is signed with the same key, from being accepted here. */ +export async function verifyAccessToken( + authorization: string | null, +): Promise { + const token = authorization?.match(/^Bearer (.+)$/)?.[1] + if (!token) throw new Error('missing_token') + const { payload } = await jwtVerify(token, jwks, { typ: 'at+jwt' }) + return payload as AccessToken +} + +/** Check that the token was granted a scope. Scopes are a + * space-separated string in the token's scope claim. */ +export function hasScope(token: AccessToken, scope: string): boolean { + return (token.scope ?? '').split(' ').includes(scope) +} +``` + +`jwtVerify` checks the signature against the published keys and rejects expired tokens, so a forged or stale token never reaches your handlers. It also pins the algorithm to the RS256 keys in the JWKS, so `alg: none` and HMAC confusion attacks fail here too. + +The `{ typ: 'at+jwt' }` option matters more than it looks. Your OAuth2 server signs [ID tokens](/docs/partners/oauth-server/tokens) with the same key as access tokens, and an ID token has no `scope` claim. Without the type check, a client could present its ID token to your API; requiring `at+jwt` (the [access token type](/docs/partners/oauth-server/tokens)) rejects it before it reaches a handler. An ID token is meant for the client that received it, never as a credential for your API. + +Two claims in the verified payload matter for the API: + +- `scope` holds the granted scopes as a single space-separated string, such as `openid profile email tasks.read`. `hasScope` splits it and looks for an exact entry. +- `sub` is the ID of the user who approved the grant. The API uses it to load that user's tasks, so a token can never read anyone else's data. + +{% info title="Build the JWKS URL from your own configuration" %} +The guard builds the JWKS URL from the `OAUTH_ISSUER` value the provider already has, the same base it uses for every other OAuth2 server call. Since [tokens](/docs/partners/oauth-server/tokens) are standard JWTs, any JWT library with JWKS support in any language can do this job. The API route just happens to live next to the consent screen here. +{% /info %} + +Continue to put the guard in front of an API route. diff --git a/src/routes/docs/partners/oauth-server/custom-scopes/step-5/+page.markdoc b/src/routes/docs/partners/oauth-server/custom-scopes/step-5/+page.markdoc new file mode 100644 index 0000000000..048f7446f2 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/custom-scopes/step-5/+page.markdoc @@ -0,0 +1,142 @@ +--- +layout: tutorial +title: Protect the API route +description: Serve tasks only to tokens that carry tasks.read. +step: 5 +--- + +With the guard in place, TaskFlow can expose its task API. This is where the scope stops being a label and becomes a rule. + +# The task data {% #data %} + +Create `provider/src/lib/tasks.ts` with an in-memory store, keyed by user ID. It stands in for your product's database so the tutorial stays focused on the OAuth side: + +```ts +// provider/src/lib/tasks.ts +// TaskFlow's task data, keyed by the owner's user ID. A stand-in for +// your product's database so the guide stays focused on OAuth. +import '@tanstack/react-start/server-only' + +export type Task = { + id: string + title: string + project: string + due: string + done: boolean +} + +const seed: Task[] = [ + { id: 'tsk_01', title: 'Finalize Q3 launch checklist', project: 'Launch', due: '2026-07-17', done: false }, + { id: 'tsk_02', title: 'Review onboarding copy', project: 'Growth', due: '2026-07-15', done: true }, + { id: 'tsk_03', title: 'Ship dark mode to beta', project: 'Product', due: '2026-07-21', done: false }, + { id: 'tsk_04', title: 'Prepare investor update', project: 'Ops', due: '2026-07-24', done: false }, +] + +const store = new Map() + +/** Every TaskFlow user gets the same starter tasks the first time + * their list is read. */ +export function tasksFor(userId: string): Task[] { + let tasks = store.get(userId) + if (!tasks) { + tasks = seed.map((t) => ({ ...t })) + store.set(userId, tasks) + } + return tasks +} + +export function addTaskFor(userId: string, title: string): Task { + const tasks = tasksFor(userId) + const task: Task = { + id: `tsk_${String(tasks.length + 1).padStart(2, '0')}`, + title, + project: 'Inbox', + due: '2026-07-31', + done: false, + } + tasks.push(task) + return task +} +``` + +# The guarded route {% #route %} + +Create `provider/src/routes/api.tasks.ts`. TanStack Start serves the `GET` and `POST` handlers at `/api/tasks`: + +```ts +// provider/src/routes/api.tasks.ts +import { createFileRoute } from '@tanstack/react-router' +import { + hasScope, + verifyAccessToken, + type AccessToken, +} from '../lib/resource-server' +import { addTaskFor, tasksFor } from '../lib/tasks' + +// Standard OAuth resource server errors (RFC 6750): 401 when the token +// itself is bad, 403 when it's valid but missing the required scope. +function unauthorized() { + return Response.json( + { error: 'invalid_token' }, + { status: 401, headers: { 'WWW-Authenticate': 'Bearer error="invalid_token"' } }, + ) +} + +function forbidden(scope: string) { + return Response.json( + { error: 'insufficient_scope', required_scope: scope }, + { + status: 403, + headers: { + 'WWW-Authenticate': `Bearer error="insufficient_scope", scope="${scope}"`, + }, + }, + ) +} + +async function authenticate(request: Request): Promise { + try { + return await verifyAccessToken(request.headers.get('authorization')) + } catch { + return null + } +} + +export const Route = createFileRoute('/api/tasks')({ + server: { + handlers: { + GET: async ({ request }) => { + const token = await authenticate(request) + if (!token) return unauthorized() + if (!hasScope(token, 'tasks.read')) return forbidden('tasks.read') + + // The token's subject is the TaskFlow user who approved access. + return Response.json({ tasks: tasksFor(token.sub!) }) + }, + + POST: async ({ request }) => { + const token = await authenticate(request) + if (!token) return unauthorized() + if (!hasScope(token, 'tasks.write')) return forbidden('tasks.write') + + const { title } = await request.json() + return Response.json( + { task: addTaskFor(token.sub!, title) }, + { status: 201 }, + ) + }, + }, + }, +}) +``` + +Each handler applies the same two checks, in order: + +1. **Authentication**: is the token real? A missing, forged, or expired token gets `401 invalid_token`. +2. **Authorization**: was this token granted the scope this operation needs? A valid token without it gets `403 insufficient_scope`. + +The two error shapes follow RFC 6750, including the `WWW-Authenticate` header, so standard OAuth client libraries understand the refusal. The distinction matters to callers: a 401 means get a new token, a 403 means ask the user for more scopes. + +Note what the `POST` handler implies: a client can hold a perfectly valid token and still be refused a write with it, whenever the user granted `tasks.read` but withheld `tasks.write` on the consent screen. + +Continue to call the API from Vantage. diff --git a/src/routes/docs/partners/oauth-server/custom-scopes/step-6/+page.markdoc b/src/routes/docs/partners/oauth-server/custom-scopes/step-6/+page.markdoc new file mode 100644 index 0000000000..72ff42e695 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/custom-scopes/step-6/+page.markdoc @@ -0,0 +1,136 @@ +--- +layout: tutorial +title: Call the API from Vantage +description: Read tasks with the granted access token and add a task composer that lives or dies by its scope. +step: 6 +--- + +Vantage already holds the access token in its session after the [token exchange](/docs/partners/oauth-server/sign-in-with-your-product/step-6). Reading tasks is one authenticated fetch away, and a small composer will exercise the write path. + +# Point Vantage at the API {% #env %} + +Add TaskFlow's API base to `consumer/.env`: + +```sh +# consumer/.env +# TaskFlow's API, called with the granted access token. +TASKFLOW_API_URL=http://localhost:4000 +``` + +# The API client {% #client %} + +Create `consumer/src/lib/taskflow.ts`. Every request carries the access token as a Bearer header, and TaskFlow's guard does the rest: + +```ts +// consumer/src/lib/taskflow.ts +// Vantage's client for TaskFlow's API. Every request carries the access +// token the user granted, and TaskFlow enforces its scopes. +import '@tanstack/react-start/server-only' + +const taskflowApi = process.env.TASKFLOW_API_URL! + +export type TaskFlowTask = { + id: string + title: string + project: string + due: string + done: boolean +} + +/** Read the user's tasks. Requires the tasks.read scope. */ +export async function fetchTasks(accessToken: string): Promise { + const res = await fetch(`${taskflowApi}/api/tasks`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!res.ok) throw new Error(`tasks failed: ${res.status}`) + const { tasks } = await res.json() + return tasks +} + +export type WriteResult = + | { created: true; task: TaskFlowTask } + | { created: false; status: number; error: string } + +/** Create a task on TaskFlow. Succeeds only when the access token + * carries the tasks.write scope; otherwise TaskFlow answers with + * 403 insufficient_scope. */ +export async function createTask( + accessToken: string, + title: string, +): Promise { + const res = await fetch(`${taskflowApi}/api/tasks`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ title }), + }) + if (res.ok) { + const { task } = await res.json() + return { created: true, task } + } + const body = await res.json().catch(() => ({ error: 'unknown' })) + return { created: false, status: res.status, error: body.error } +} +``` + +`createTask` does not check any scope itself. Vantage cannot know what the user granted until it tries; the refusal comes from TaskFlow, which is the only side that can be trusted to enforce it. + +# Keep the granted scope {% #granted-scope %} + +The token response reports which scopes were granted, which matters now that the user picks them individually on the consent screen. Store it in the session in `consumer/src/routes/oauth.callback.tsx`, next to the access token: + +```ts +// consumer/src/routes/oauth.callback.tsx +await session.update({ + accessToken: tokens.access_token, + grantedScope: tokens.scope, + user, + state: undefined, +}) +``` + +Add the matching `grantedScope?: string` field to `SessionData` in `consumer/src/lib/oauth.ts`. + +# Load tasks and add the composer {% #dashboard %} + +Replace the dashboard loader in `consumer/src/routes/dashboard.tsx` and add a server function for the write: + +```tsx +// consumer/src/routes/dashboard.tsx (loader and write) +const loadDashboard = createServerFn().handler( + async (): Promise => { + const session = await vantageSession() + const { user, accessToken, grantedScope } = session.data + if (!user || !accessToken) throw redirect({ to: '/' }) + + // Reads the user's tasks from TaskFlow's API with the granted + // access token. Works because the user granted tasks.read. + const tasks = await fetchTasks(accessToken) + + return { user, grantedScope: grantedScope ?? '', tasks } + }, +) + +const addTask = createServerFn({ method: 'POST' }) + .validator((d: { title: string }) => d) + .handler(async ({ data }): Promise => { + const session = await vantageSession() + const { accessToken } = session.data + if (!accessToken) throw redirect({ to: '/' }) + + // TaskFlow only accepts this if the token carries tasks.write. + return createTask(accessToken, data.title) + }) +``` + +The component renders the granted scopes as chips, the task list, and an **Add task** composer under it. On submit it calls `addTask`; when the result is `created` it refreshes the list with `router.invalidate()`, and when it is not, it shows TaskFlow's refusal next to the composer. The full component is in the [tutorial repository](https://github.com/appwrite-community/oauth-guide-custom-scopes). + +# The payoff {% #payoff %} + +![Vantage dashboard showing live TaskFlow tasks and the task composer](/images/docs/oauth-server/scopes-guide/vantage-tasks-dashboard.avif) + +The dashboard shows the user's tasks, fetched live from TaskFlow's API with the granted token, with the composer ready below them. What happens when you use it depends entirely on what the user granted on the consent screen, which is exactly what the final step walks through. + +Continue to run the whole flow. diff --git a/src/routes/docs/partners/oauth-server/custom-scopes/step-7/+page.markdoc b/src/routes/docs/partners/oauth-server/custom-scopes/step-7/+page.markdoc new file mode 100644 index 0000000000..1f90027370 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/custom-scopes/step-7/+page.markdoc @@ -0,0 +1,62 @@ +--- +layout: tutorial +title: Run the flow +description: Grant the read scope, watch a write get refused, then grant the write scope and watch it succeed. +step: 7 +--- + +Everything is wired up. Run the flow twice: once granting only read access, and once granting the write too. The same button on the dashboard behaves differently each time, and the only thing that changed is what the user agreed to. + +# Start both apps {% #start %} + +In two terminals: + +```sh +# in consumer/ +pnpm dev +``` + +```sh +# in provider/ +pnpm dev +``` + +# Grant read, withhold write {% #withhold %} + +![TaskFlow consent screen with the write permission switched off](/images/docs/oauth-server/scopes-guide/taskflow-consent-choice.avif) + +Open `http://localhost:4100` and click **Sign in with TaskFlow**. On the consent screen, switch **Create and update your tasks** off and authorize. The OAuth2 server narrows the grant to what was actually approved, so the access token comes back carrying `tasks.read` but not `tasks.write`. + +![Vantage dashboard with the write attempt refused as insufficient_scope](/images/docs/oauth-server/scopes-guide/vantage-write-denied.avif) + +On the dashboard, your tasks render live, so the read scope is doing its job. Now type a task and click **Add task**: TaskFlow refuses it with `403 insufficient_scope`. Vantage holds a perfectly valid token and still cannot write with it, because the user never agreed to that. + +# Grant the write {% #grant %} + +Sign out of Vantage and sign in again. This time leave every permission on and authorize. The previous grant did not cover `tasks.write`, so the consent screen asks again. + +![Vantage dashboard with tasks.write granted and the new task in the list](/images/docs/oauth-server/scopes-guide/vantage-write-granted.avif) + +Add the same task again. It lands in the list, and the `tasks.write` chip sits in the granted scopes. Nothing about TaskFlow's API changed between the two runs; the same guard that refused the write now lets it through, because the token finally carries the scope it checks for. + +# What each piece did {% #recap %} + +- **The OAuth2 server** published the scopes, put them in front of the user as individual choices, and stamped the approved subset onto a signed access token. +- **TaskFlow's API** verified each token against the project's JWKS and turned the `scope` claim into an allow-or-deny decision per operation. +- **Vantage** requested the scopes it can use, and its access ends exactly where the user's grant does. + +The full source for both apps is on GitHub at [appwrite-community/oauth-guide-custom-scopes](https://github.com/appwrite-community/oauth-guide-custom-scopes). + +# Next steps {% #next-steps %} + +{% cards %} +{% cards_item href="/docs/partners/oauth-server/tokens" title="Tokens" %} +Refresh access tokens, and revoke them on sign-out. +{% /cards_item %} +{% cards_item href="/docs/partners/oauth-server/scopes" title="Scopes" %} +The scope model in full, including limits and the built-in scopes. +{% /cards_item %} +{% cards_item href="/docs/partners/oauth-server/device-flow" title="Device flow" %} +Support TVs, CLIs, and other input-constrained devices. +{% /cards_item %} +{% /cards %} diff --git a/src/routes/docs/partners/oauth-server/device-flow/+page.markdoc b/src/routes/docs/partners/oauth-server/device-flow/+page.markdoc new file mode 100644 index 0000000000..0c5e4ea45b --- /dev/null +++ b/src/routes/docs/partners/oauth-server/device-flow/+page.markdoc @@ -0,0 +1,262 @@ +--- +layout: article +title: Device flow +description: Authorize TVs, CLIs, and other input-constrained devices against your Appwrite OAuth2 server with the device authorization grant. +back: /docs/partners/oauth-server +--- + +The device authorization grant (RFC 8628) lets a client request access even when it cannot open a browser or accept a callback. A TV app, command-line tool, or hardware device shows the user a code, and the user completes authorization on a phone or computer. + +This flow involves two applications: + +- The **device client** is the third-party application requesting access. It communicates with Appwrite over HTTP. +- The **verification page** belongs to your project. You build this page with an Appwrite Client SDK so the user can sign in, review the request, and approve or reject it. + +# How it works {% #how-it-works %} + +{% only_dark %} +![The device flow between the device, the OAuth2 server, and the user's phone](/images/docs/oauth-server/dark/diagram-device-flow.avif) +{% /only_dark %} +{% only_light %} +![The device flow between the device, the OAuth2 server, and the user's phone](/images/docs/oauth-server/diagram-device-flow.avif) +{% /only_light %} + +1. The device client starts a device authorization. Appwrite returns a device code, a shorter user code, and the URL of your verification page. +2. The device shows the user code and verification URL to the user. +3. The user opens the verification page on a second device, confirms the code, signs in, and reviews the request. +4. The verification page connects the pending request to the signed-in user and asks them to approve or reject it. +5. While this happens, the original device polls the token endpoint. After approval, the next successful poll returns tokens directly to the device. + +# Configure device flow {% #configure %} + +Device flow must be enabled in both the OAuth2 server settings and the client settings. + +## Configure the OAuth2 server {% #configure-server %} + +Use `project.updateOAuth2Server` from a Server SDK to configure the verification page and code behavior: + +| Setting | Purpose | +| --- | --- | +| `verificationUrl` | The URL of the verification page you host, such as `https://your-product.com/activate`. This setting is required for device flow. | +| `userCodeLength` | The number of characters in the user code. It can be from 6 to 12 and defaults to 8. | +| `userCodeFormat` | The characters used in the user code: `numeric`, `alphabetic`, or `alphanumeric`. The default is `alphanumeric`. | +| `deviceCodeDuration` | How long the device code and user code remain valid, in seconds. It can be from 60 to 1,800 and defaults to 600. | + +{% info title="The call replaces the whole configuration" %} +`updateOAuth2Server` sets the full OAuth2 server configuration in one call. Include your existing authorization URL, scopes, and other settings when you add the device flow settings. +{% /info %} + +## Enable device flow for the client {% #enable-client %} + +The client must also have **Device flow** enabled. Turn it on when you create or update the client in the Console, or set `deviceFlow: true` with a Server SDK. Devices that cannot protect a client secret should be registered as public clients. See [Clients](/docs/partners/oauth-server/clients#register) for client registration. + +# Integrate the device client {% #integrate-device %} + +The device client uses HTTP for the device authorization and token requests. + +## 1. Start a device authorization {% #start %} + +Send the client ID and requested scopes to the device authorization endpoint as JSON: + +{% multicode %} +```curl +curl -X POST 'https://.cloud.appwrite.io/v1/oauth2//device_authorization' \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -d '{ + "client_id": "", + "scope": "openid profile tasks.read" + }' +``` +```hurl +POST https://.cloud.appwrite.io/v1/oauth2//device_authorization +Content-Type: application/json +Accept: application/json +{ + "client_id": "", + "scope": "openid profile tasks.read" +} +``` +{% /multicode %} + +The request can also include: + +- `authorization_details` for a JSON-encoded rich authorization request. +- `resource` for one resource indicator URI or an array of URIs. +- `audience` as a compatibility alias for one resource indicator. + +See [Scopes](/docs/partners/oauth-server/scopes#rich-authorization-requests) for authorization details and resource restrictions. + +Appwrite returns the codes, expiration, and polling interval: + +```json +{ + "device_code": "8575375669b9031cb5371b0ee39985c2...", + "user_code": "3RG9K9QF", + "verification_uri": "https://your-product.com/activate", + "verification_uri_complete": "https://your-product.com/activate?user_code=3RG9K9QF", + "expires_in": 600, + "interval": 1 +} +``` + +Show the `user_code` and `verification_uri` on the device. You can also present `verification_uri_complete` as a link or QR code, which opens the verification page with the code already filled in. + +## 2. Poll for tokens {% #poll %} + +Start polling the token endpoint in the background. Wait at least the number of seconds in `interval` between requests: + +{% multicode %} +```curl +curl -X POST 'https://.cloud.appwrite.io/v1/oauth2//token' \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -d '{ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": "", + "client_id": "" + }' +``` +```hurl +POST https://.cloud.appwrite.io/v1/oauth2//token +Content-Type: application/json +Accept: application/json +{ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": "", + "client_id": "" +} +``` +{% /multicode %} + +Handle each response according to its `error` value: + +- `authorization_pending`: The user has not finished authorization. Wait for `interval`, then poll again. +- `slow_down`: The client is polling too quickly. Increase the delay before the next request. +- `expired_token`: The device code expired. Start a new device authorization and show the new user code. +- `access_denied`: The user rejected the request. Stop polling and let them restart if they want to try again. + +# Build the verification page {% #verification-page %} + +The `verificationUrl` page runs on the user's second device and completes the interactive part of the flow. + +1. Read `user_code` from the URL search parameters. If it is missing, show an input where the user can enter the code displayed on the original device. +2. Show the code and ask the user to confirm that it matches the original device. +3. Make sure the user is signed in to your project. If they are signed out, send them through your sign-in or sign-up flow and return them to the verification page with the user code intact. +4. Pass the confirmed code to `oauth2.createGrant`. This connects the pending device request to the signed-in user and returns the grant record. + +{% multicode %} +```client-web +import { Client, Oauth2 } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const oauth2 = new Oauth2(client); + +const result = await oauth2.createGrant({ + userCode: '' +}); + +console.log(result); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Oauth2 oauth2 = Oauth2(client); + +Oauth2Grant result = await oauth2.createGrant( + userCode: '', +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let oauth2 = Oauth2(client) + +let oauth2Grant = try await oauth2.createGrant( + user_code: "" +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.createGrant( + user_code = "", +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.createGrant( + "", // user_code + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Oauth2 } from 'react-native-appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const oauth2 = new Oauth2(client); + +const result = await oauth2.createGrant({ + userCode: '' +}); + +console.log(result); +``` +{% /multicode %} + +Use the returned grant to show the client, requested scopes, and authorization details. Then let the user approve or reject it with the same [consent flow](/docs/partners/oauth-server/authorization#consent) used for browser authorization. + +# Finish on the original device {% #finish %} + +The original device keeps polling while the user completes the verification page. After approval, the token endpoint returns access and refresh tokens, plus an ID token when `openid` was granted. The device uses these tokens directly. Device flow has no authorization code or redirect callback on the original device. + +```json +{ + "access_token": "", + "refresh_token": "", + "id_token": "", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "openid profile tasks.read", + "authorization_details": null +} +``` + +Store the newest refresh token securely and use the token response's granted scopes to determine which features are available. See [Tokens](/docs/partners/oauth-server/tokens) for validation, refresh, and revocation. diff --git a/src/routes/docs/partners/oauth-server/quick-start/+page.markdoc b/src/routes/docs/partners/oauth-server/quick-start/+page.markdoc new file mode 100644 index 0000000000..91c48791bb --- /dev/null +++ b/src/routes/docs/partners/oauth-server/quick-start/+page.markdoc @@ -0,0 +1,847 @@ +--- +layout: article +title: OAuth2 server quick start +description: Enable Appwrite's OAuth2 server, register a client, and run your first authorization code sign-in end to end. +back: /docs/partners/oauth-server +--- + +This guide turns your project into an OAuth2 provider and runs one sign-in through it. By the end you will have an enabled server, a registered client, and an access token issued by your project. + +The examples follow two apps, the same pair the [tutorials](/docs/partners/oauth-server/sign-in-with-your-product/step-1) build out in full: + +- **TaskFlow** (`http://taskflow.localhost`): your product and the **OAuth2 provider**, also called the authorization server. It authenticates users, presents the consent screen, and issues tokens. +- **Vantage** (`http://vantage.localhost`): the third-party **consumer**, called the client in OAuth2. It sends users to TaskFlow for authorization and receives tokens after they approve access. + +# Enable the server {% #enable %} + +In the Console, open **Auth**, select the **OAuth2 server** tab, and turn on **Enable OAuth2 server**. + +{% only_dark %} +![Enabling the OAuth2 server in the Appwrite Console](/images/docs/oauth-server/dark/oauth2-server-settings.avif) +{% /only_dark %} +{% only_light %} +![Enabling the OAuth2 server in the Appwrite Console](/images/docs/oauth-server/oauth2-server-settings.avif) +{% /only_light %} + +Set the **Authorization URL** to the page you will host the consent screen on. This is where Appwrite redirects users during authorization, and where you present them the details of the request so they can approve or cancel. Point it at `http://taskflow.localhost/consent`, where TaskFlow will host its consent screen. Nothing needs to run there yet. The `openid`, `profile`, and `email` scopes are always included; add any scopes your custom APIs will support. + +You can also enable and configure the server with a [Server SDK](/docs/sdks#server) using the `updateOAuth2Server` method. + +{% info title="Required scope" %} +The API key used for this call needs the `project.write` scope. +{% /info %} + +{% multicode %} +```server-nodejs +import { Client, Project } from 'node-appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject('') // Your project ID + .setKey(''); // Your secret API key + +const project = new Project(client); + +const result = await project.updateOAuth2Server({ + enabled: false, + authorizationUrl: 'https://example.com', + scopes: [], // optional + authorizationDetailsTypes: [], // optional + accessTokenDuration: 60, // optional + refreshTokenDuration: 60, // optional + publicAccessTokenDuration: 60, // optional + publicRefreshTokenDuration: 60, // optional + confidentialPkce: false, // optional + verificationUrl: 'https://example.com', // optional + userCodeLength: 6, // optional + userCodeFormat: 'numeric', // optional + deviceCodeDuration: 60 // optional +}); +``` +```server-deno +import { Client, Project } from "npm:node-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject('') // Your project ID + .setKey(''); // Your secret API key + +const project = new Project(client); + +const result = await project.updateOAuth2Server({ + enabled: false, + authorizationUrl: 'https://example.com', + scopes: [], // optional + authorizationDetailsTypes: [], // optional + accessTokenDuration: 60, // optional + refreshTokenDuration: 60, // optional + publicAccessTokenDuration: 60, // optional + publicRefreshTokenDuration: 60, // optional + confidentialPkce: false, // optional + verificationUrl: 'https://example.com', // optional + userCodeLength: 6, // optional + userCodeFormat: 'numeric', // optional + deviceCodeDuration: 60 // optional +}); +``` +```server-php +```php +setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + ->setProject('') // Your project ID + ->setKey(''); // Your secret API key + +$project = new Project($client); + +$result = $project->updateOAuth2Server( + enabled: false, + authorizationUrl: 'https://example.com', + scopes: [], // optional + authorizationDetailsTypes: [], // optional + accessTokenDuration: 60, // optional + refreshTokenDuration: 60, // optional + publicAccessTokenDuration: 60, // optional + publicRefreshTokenDuration: 60, // optional + confidentialPkce: false, // optional + verificationUrl: 'https://example.com', // optional + userCodeLength: 6, // optional + userCodeFormat: 'numeric', // optional + deviceCodeDuration: 60 // optional +);``` +``` +```server-python +from appwrite.client import Client +from appwrite.services.project import Project +from appwrite.models import Project as ProjectModel + +client = Client() +client.set_endpoint('https://.cloud.appwrite.io/v1') # Your API Endpoint +client.set_project('') # Your project ID +client.set_key('') # Your secret API key + +project = Project(client) + +result: ProjectModel = project.update_o_auth2_server( + enabled = False, + authorization_url = 'https://example.com', + scopes = [], # optional + authorization_details_types = [], # optional + access_token_duration = 60, # optional + refresh_token_duration = 60, # optional + public_access_token_duration = 60, # optional + public_refresh_token_duration = 60, # optional + confidential_pkce = False, # optional + verification_url = 'https://example.com', # optional + user_code_length = 6, # optional + user_code_format = 'numeric', # optional + device_code_duration = 60 # optional +) + +print(result.model_dump()) +``` +```server-ruby +require 'appwrite' + +include Appwrite + +client = Client.new + .set_endpoint('https://.cloud.appwrite.io/v1') # Your API Endpoint + .set_project('') # Your project ID + .set_key('') # Your secret API key + +project = Project.new(client) + +result = project.update_o_auth2_server( + enabled: false, + authorization_url: 'https://example.com', + scopes: [], # optional + authorization_details_types: [], # optional + access_token_duration: 60, # optional + refresh_token_duration: 60, # optional + public_access_token_duration: 60, # optional + public_refresh_token_duration: 60, # optional + confidential_pkce: false, # optional + verification_url: 'https://example.com', # optional + user_code_length: 6, # optional + user_code_format: 'numeric', # optional + device_code_duration: 60 # optional +) +``` +```server-dart +import 'package:dart_appwrite/dart_appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject('') // Your project ID + .setKey(''); // Your secret API key + +Project project = Project(client); + +Project result = await project.updateOAuth2Server( + enabled: false, + authorizationUrl: 'https://example.com', + scopes: [], // (optional) + authorizationDetailsTypes: [], // (optional) + accessTokenDuration: 60, // (optional) + refreshTokenDuration: 60, // (optional) + publicAccessTokenDuration: 60, // (optional) + publicRefreshTokenDuration: 60, // (optional) + confidentialPkce: false, // (optional) + verificationUrl: 'https://example.com', // (optional) + userCodeLength: 6, // (optional) + userCodeFormat: 'numeric', // (optional) + deviceCodeDuration: 60, // (optional) +); +``` +```server-dotnet +```csharp +using Appwrite; +using Appwrite.Models; +using Appwrite.Services; + +Client client = new Client() + .SetEndPoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .SetProject("") // Your project ID + .SetKey(""); // Your secret API key + +Project project = new Project(client); + +Project result = await project.UpdateOAuth2Server( + enabled: false, + authorizationUrl: "https://example.com", + scopes: new List(), // optional + authorizationDetailsTypes: new List(), // optional + accessTokenDuration: 60, // optional + refreshTokenDuration: 60, // optional + publicAccessTokenDuration: 60, // optional + publicRefreshTokenDuration: 60, // optional + confidentialPkce: false, // optional + verificationUrl: "https://example.com", // optional + userCodeLength: 6, // optional + userCodeFormat: "numeric", // optional + deviceCodeDuration: 60 // optional +);``` +``` +```server-kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Project + +val client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + .setKey("") // Your secret API key + +val project = Project(client) + +val response = project.updateOAuth2Server( + enabled = false, + authorizationUrl = "https://example.com", + scopes = listOf(), // optional + authorizationDetailsTypes = listOf(), // optional + accessTokenDuration = 60, // optional + refreshTokenDuration = 60, // optional + publicAccessTokenDuration = 60, // optional + publicRefreshTokenDuration = 60, // optional + confidentialPkce = false, // optional + verificationUrl = "https://example.com", // optional + userCodeLength = 6, // optional + userCodeFormat = "numeric", // optional + deviceCodeDuration = 60 // optional +) +``` +```server-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Project; + +Client client = new Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + .setKey(""); // Your secret API key + +Project project = new Project(client); + +project.updateOAuth2Server( + false, // enabled + "https://example.com", // authorizationUrl + List.of(), // scopes (optional) + List.of(), // authorizationDetailsTypes (optional) + 60, // accessTokenDuration (optional) + 60, // refreshTokenDuration (optional) + 60, // publicAccessTokenDuration (optional) + 60, // publicRefreshTokenDuration (optional) + false, // confidentialPkce (optional) + "https://example.com", // verificationUrl (optional) + 6, // userCodeLength (optional) + "numeric", // userCodeFormat (optional) + 60, // deviceCodeDuration (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + System.out.println(result); + }) +); +``` +```server-swift +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + .setKey("") // Your secret API key + +let project = Project(client) + +let project = try await project.updateOAuth2Server( + enabled: false, + authorizationUrl: "https://example.com", + scopes: [], // optional + authorizationDetailsTypes: [], // optional + accessTokenDuration: 60, // optional + refreshTokenDuration: 60, // optional + publicAccessTokenDuration: 60, // optional + publicRefreshTokenDuration: 60, // optional + confidentialPkce: false, // optional + verificationUrl: "https://example.com", // optional + userCodeLength: 6, // optional + userCodeFormat: "numeric", // optional + deviceCodeDuration: 60 // optional +) +``` +```server-go +package main + +import ( + "fmt" + "github.com/repoowner/reponame/client" + "github.com/repoowner/reponame/project" +) + +client := client.New( + client.WithEndpoint("https://.cloud.appwrite.io/v1") + client.WithProject("") + client.WithKey("") +) + +service := project.New(client) + +response, error := service.UpdateOAuth2Server( + false, + "https://example.com", + project.WithUpdateOAuth2ServerScopes([]string{}), + project.WithUpdateOAuth2ServerAuthorizationDetailsTypes([]string{}), + project.WithUpdateOAuth2ServerAccessTokenDuration(60), + project.WithUpdateOAuth2ServerRefreshTokenDuration(60), + project.WithUpdateOAuth2ServerPublicAccessTokenDuration(60), + project.WithUpdateOAuth2ServerPublicRefreshTokenDuration(60), + project.WithUpdateOAuth2ServerConfidentialPkce(false), + project.WithUpdateOAuth2ServerVerificationUrl("https://example.com"), + project.WithUpdateOAuth2ServerUserCodeLength(6), + project.WithUpdateOAuth2ServerUserCodeFormat("numeric"), + project.WithUpdateOAuth2ServerDeviceCodeDuration(60), +) +``` +```server-rust +use appwrite::Client; +use appwrite::services::Project; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = Client::new(); + client.set_endpoint("https://.cloud.appwrite.io/v1"); // Your API Endpoint + client.set_project(""); // Your project ID + client.set_key(""); // Your secret API key + + let project = Project::new(&client); + + let result = project.update_o_auth2_server( + false, + "https://example.com", + Some(vec![]), // optional + Some(vec![]), // optional + Some(60), // optional + Some(60), // optional + Some(60), // optional + Some(60), // optional + Some(false), // optional + Some("https://example.com"), // optional + Some(6), // optional + Some("numeric"), // optional + Some(60) // optional + ).await?; + + let _ = result; + + Ok(()) +} +``` +{% /multicode %} + +# Copy the discovery URL {% #discovery %} + +Once enabled, the server publishes an OpenID Connect discovery document. Integrators point their OAuth or OIDC library at this URL and it learns every endpoint automatically. + +{% only_dark %} +![The OIDC discovery URL in the Appwrite Console](/images/docs/oauth-server/dark/oauth2-server-discovery.avif) +{% /only_dark %} +{% only_light %} +![The OIDC discovery URL in the Appwrite Console](/images/docs/oauth-server/oauth2-server-discovery.avif) +{% /only_light %} + +Open it in a browser to confirm the server is live. It returns JSON describing the authorization, token, userinfo, and JWKS endpoints, and more. + +# Register a client {% #register %} + +Each app that integrates with your project registers as a client. Here that is Vantage. The **redirect URI** it declares is a URL on Vantage, `http://vantage.localhost/auth/redirect`, where the OAuth2 server sends users back with the authorization code. Any HTTP or HTTPS URL works. + +Open the **Apps** sub-tab and create a client. Name it `Vantage`, add the redirect URI, and choose a type. Pick **Confidential** for this walkthrough so you get a secret to authenticate the token exchange. Copy the secret when it is shown, because it appears only once. + +{% only_dark %} +![Creating an OAuth2 client](/images/docs/oauth-server/dark/oauth2-server-create-app.avif) +{% /only_dark %} +{% only_light %} +![Creating an OAuth2 client](/images/docs/oauth-server/oauth2-server-create-app.avif) +{% /only_light %} + +Clients can also be registered from code with the `apps` service in the [Client SDKs](/docs/sdks#client). Any signed-in user on your project can register an app, which enables self-serve registration for integrators. Creating a client needs only a name and a redirect URI: + +{% multicode %} +```client-web +import { Client, Apps, ID } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const app = await apps.create({ + appId: ID.unique(), + name: 'Vantage', + redirectUris: ['http://vantage.localhost/auth/redirect'], +}); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Apps apps = Apps(client); + +App app = await apps.create( + appId: ID.unique(), + name: 'Vantage', + redirectUris: ['http://vantage.localhost/auth/redirect'], +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let apps = Apps(client) + +let app = try await apps.create( + appId: ID.unique(), + name: "Vantage", + redirectUris: ["http://vantage.localhost/auth/redirect"] +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.ID +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val app = apps.create( + appId = ID.unique(), + name = "Vantage", + redirectUris = listOf("http://vantage.localhost/auth/redirect") +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.ID; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.create( + ID.unique(), // appId + "Vantage", // name + List.of("http://vantage.localhost/auth/redirect"), // redirectUris + null, // description (optional) + null, // clientUri (optional) + null, // logoUri (optional) + null, // privacyPolicyUrl (optional) + null, // termsUrl (optional) + null, // contacts (optional) + null, // tagline (optional) + null, // tags (optional) + null, // images (optional) + null, // supportUrl (optional) + null, // dataDeletionUrl (optional) + null, // postLogoutRedirectUris (optional) + null, // enabled (optional) + null, // type (optional) + null, // deviceFlow (optional) + null, // teamId (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Apps, ID } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const app = await apps.create({ + appId: ID.unique(), + name: 'Vantage', + redirectUris: ['http://vantage.localhost/auth/redirect'], +}); +``` +{% /multicode %} + +Updating a client accepts the full set of options, from consent screen branding to logout URIs and the device flow: + +{% multicode %} +```client-web +import { Client, Apps } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const app = await apps.update({ + appId: '', + name: 'Vantage', + description: 'A dashboard that signs in with TaskFlow.', + logoUri: 'https://example.com/logo.png', + redirectUris: ['http://vantage.localhost/auth/redirect'], + enabled: true, +}); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Apps apps = Apps(client); + +App app = await apps.update( + appId: '', + name: 'Vantage', + description: 'A dashboard that signs in with TaskFlow.', + logoUri: 'https://example.com/logo.png', + redirectUris: ['http://vantage.localhost/auth/redirect'], + enabled: true, +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let apps = Apps(client) + +let app = try await apps.update( + appId: "", + name: "Vantage", + description: "A dashboard that signs in with TaskFlow.", + logoUri: "https://example.com/logo.png", + redirectUris: ["http://vantage.localhost/auth/redirect"], + enabled: true +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.services.Apps + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val apps = Apps(client) + +val app = apps.update( + appId = "", + name = "Vantage", + description = "A dashboard that signs in with TaskFlow.", + logoUri = "https://example.com/logo.png", + redirectUris = listOf("http://vantage.localhost/auth/redirect"), + enabled = true +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Apps; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Apps apps = new Apps(client); + +apps.update( + "", // appId + "Vantage", // name + "A dashboard that signs in with TaskFlow.", // description (optional) + null, // clientUri (optional) + "https://example.com/logo.png", // logoUri (optional) + null, // privacyPolicyUrl (optional) + null, // termsUrl (optional) + null, // contacts (optional) + null, // tagline (optional) + null, // tags (optional) + null, // images (optional) + null, // supportUrl (optional) + null, // dataDeletionUrl (optional) + true, // enabled (optional) + List.of("http://vantage.localhost/auth/redirect"), // redirectUris (optional) + null, // postLogoutRedirectUris (optional) + null, // type (optional) + null, // deviceFlow (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Apps } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const apps = new Apps(client); + +const app = await apps.update({ + appId: '', + name: 'Vantage', + description: 'A dashboard that signs in with TaskFlow.', + logoUri: 'https://example.com/logo.png', + redirectUris: ['http://vantage.localhost/auth/redirect'], + enabled: true, +}); +``` +{% /multicode %} + +The same `apps` service is available in the [Server SDKs](/docs/sdks#server) with an API key. See [Clients](/docs/partners/oauth-server/clients) for the full set of options. + +# Run the authorization code flow {% #flow %} + +With the server enabled and a client registered, you can run a sign-in. The flow has four steps: send the user to authorize, approve the grant, exchange the returned code for tokens, and use the access token to read their profile. + +## 1. Send the user to the authorization endpoint {% #send-to-authorization %} + +Vantage begins the sign-in by sending the user's browser to TaskFlow's authorization endpoint. This is the URL behind Vantage's **Sign in with TaskFlow** button. + +The URL uses TaskFlow's Appwrite API endpoint because its Appwrite project is acting as the authorization server. Replace `` with the region from your API endpoint, `` with TaskFlow's Appwrite project ID, and `` with the ID generated when you registered Vantage: + +{% multicode %} +```text +https://.cloud.appwrite.io/v1/oauth2//authorize + ?client_id= + &redirect_uri=http://vantage.localhost/auth/redirect + &response_type=code + &scope=openid profile email + &state= +``` +```curl +curl -G 'https://.cloud.appwrite.io/v1/oauth2//authorize' \ + --data-urlencode 'client_id=' \ + --data-urlencode 'redirect_uri=http://vantage.localhost/auth/redirect' \ + --data-urlencode 'response_type=code' \ + --data-urlencode 'scope=openid profile email' \ + --data-urlencode 'state=' +``` +```hurl +GET https://.cloud.appwrite.io/v1/oauth2//authorize +[Query] +client_id: +redirect_uri: http://vantage.localhost/auth/redirect +response_type: code +scope: openid profile email +state: +``` +{% /multicode %} + +Each query parameter tells TaskFlow how to handle the request: + +| Parameter | Meaning | +| --- | --- | +| `client_id` | Identifies Vantage as the client requesting access. | +| `redirect_uri` | Tells TaskFlow where to return the browser after the user approves or cancels. It must match a URI registered for Vantage. | +| `response_type=code` | Requests an authorization code. The browser receives this temporary code, then Vantage's server exchanges it for tokens in step 3. | +| `scope` | Lists the access Vantage is requesting. `openid` starts an OpenID Connect sign-in, while `profile` and `email` request the user's basic profile and email claims. | +| `state` | Carries a random value created and stored by Vantage. Vantage compares it with the value returned after authorization to confirm the response belongs to the same sign-in request. | + +When you open this URL, Appwrite validates the request and checks for an active TaskFlow user session. + +## 2. Review and approve Vantage's request {% #approve-grant %} + +After Vantage opens the authorization endpoint: + +- **Appwrite creates a grant.** For a signed-in user, Appwrite creates a pending authorization request that connects the user, Vantage, the requested scopes, and Vantage's redirect URI. This pending request is called a grant. + +- **Appwrite redirects to TaskFlow.** The **authorization endpoint** is the Appwrite Cloud URL Vantage opens. The **authorization URL** is TaskFlow's consent page, where Appwrite sends the browser with a `grant_id`. + +- **TaskFlow presents the request.** A complete consent screen uses the grant ID to show that Vantage is asking for the user's profile and email, then lets the user approve or cancel. The [Authorization](/docs/partners/oauth-server/authorization#consent) guide shows how to build this screen. + +- **Copy the grant ID.** This quick start does not build TaskFlow's consent screen, so the browser lands on `http://taskflow.localhost/consent` and finds nothing there. Copy the `grant_id` from the address bar to continue manually. + +- **Sign in if the grant ID is missing.** A URL without a `grant_id` means the user does not have an active TaskFlow session. Sign in a user on the project, then open Vantage's authorization URL again. + +- **Approve Vantage's request.** Send the `grant_id` to the approval endpoint using the same TaskFlow user's session. A browser sends the session cookie automatically. From another environment, send the same cookie yourself: it is named `a_session_` and holds the session token. Use this request: + +{% multicode %} +```curl +curl -X POST 'https://.cloud.appwrite.io/v1/oauth2//approve' \ + -H 'Content-Type: application/json' \ + -H 'Cookie: a_session_=' \ + -d '{ + "grant_id": "" + }' +``` +```hurl +POST https://.cloud.appwrite.io/v1/oauth2//approve +Content-Type: application/json +Cookie: a_session_= +{ + "grant_id": "" +} +``` +{% /multicode %} + +- **Return to Vantage.** After approval, Appwrite redirects the browser to Vantage's registered redirect URI with an authorization `code` in the query string. Vantage uses this code in the next step. + +## 3. Exchange the code for tokens {% #exchange-code %} + +Vantage's server sends the code to the token endpoint. A confidential client passes its `client_secret`; a public client passes its PKCE `code_verifier` instead. + +{% multicode %} +```curl +curl -X POST 'https://.cloud.appwrite.io/v1/oauth2//token' \ + -H 'Content-Type: application/json' \ + -d '{ + "grant_type": "authorization_code", + "code": "", + "redirect_uri": "http://vantage.localhost/auth/redirect", + "client_id": "", + "client_secret": "", + "code_verifier": "" + }' +``` +```hurl +POST https://.cloud.appwrite.io/v1/oauth2//token +Content-Type: application/json +{ + "grant_type": "authorization_code", + "code": "", + "redirect_uri": "http://vantage.localhost/auth/redirect", + "client_id": "", + "client_secret": "", + "code_verifier": "" +} +``` +{% /multicode %} + +The response contains an access token, a refresh token, and an ID token: + +```json +{ + "access_token": "eyJ0eXAiOiJhdCtqd3Qi...", + "token_type": "Bearer", + "expires_in": 28800, + "refresh_token": "eyJ0eXAiOiJKV1Qi...", + "scope": "openid profile email", + "id_token": "eyJ0eXAiOiJKV1Qi..." +} +``` + +## 4. Read the user's profile {% #read-profile %} + +Call the userinfo endpoint with the access token to confirm the sign-in worked end to end: + +{% multicode %} +```curl +curl 'https://.cloud.appwrite.io/v1/oauth2//userinfo' \ + -H 'Authorization: Bearer ' +``` +```hurl +GET https://.cloud.appwrite.io/v1/oauth2//userinfo +Authorization: Bearer +``` +{% /multicode %} + +```json +{ + "sub": "6a5138d1971d49a87f0a", + "email": "walter@example.com", + "email_verified": true, + "name": "Walter O'Brien", + "updated_at": 1784040398 +} +``` + +See [Tokens](/docs/partners/oauth-server/tokens) for token structure, validation, refresh, and revocation. + +# Next steps {% #next-steps %} + +{% cards %} +{% cards_item href="/docs/partners/oauth-server/authorization" title="Authorization" %} +Host your consent screen and drive the authorize, grant, and approve steps. +{% /cards_item %} +{% cards_item href="/docs/partners/oauth-server/clients" title="Clients" %} +Confidential vs public clients, secrets, and rotation. +{% /cards_item %} +{% cards_item href="/docs/partners/oauth-server/tokens" title="Tokens" %} +Token lifetimes, refresh with rotation, introspection, and revocation. +{% /cards_item %} +{% /cards %} diff --git a/src/routes/docs/partners/oauth-server/scopes/+page.markdoc b/src/routes/docs/partners/oauth-server/scopes/+page.markdoc new file mode 100644 index 0000000000..effca6f659 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/scopes/+page.markdoc @@ -0,0 +1,366 @@ +--- +layout: article +title: Scopes +description: The built-in OpenID Connect scopes and the custom scopes clients can request from your Appwrite OAuth2 server. +back: /docs/partners/oauth-server +--- + +Scopes are the permissions a client asks for during authorization. The user sees the requested scopes on the consent screen and approves or declines them. The access token the server issues carries the scopes that were granted. + +# Built-in scopes {% #built-in %} + +Four OpenID Connect scopes are always available and cannot be removed: + +| Scope | Grants access to | +| --- | --- | +| `openid` | The user's subject identifier. Required for OpenID Connect and to receive an ID token. | +| `profile` | The user's profile claims, such as their name. | +| `email` | The user's email address. | +| `phone` | The user's phone number. | + +These appear as locked entries in the **Scopes** field on the OAuth2 server settings. A client requests them by listing them in the `scope` parameter on the authorization request, space-separated, for example `openid profile email`. + +# Custom scopes {% #custom %} + +Beyond the built-in scopes, you define your own to represent permissions in your product. A common naming pattern is `resource.action`, such as `games.read` or `billing.write`. Some products also define an `admin` scope for full access. If you use a broad scope, describe it clearly on the consent screen and enforce it consistently in your API. + +Add custom scopes in the **Scopes** field on the OAuth2 server settings, or with the `scopes` array on the `updateOAuth2Server` method. The `openid`, `profile`, `email`, and `phone` scopes are always merged in, so you only list the custom ones. + +{% only_dark %} +![Configuring scopes on the OAuth2 server](/images/docs/oauth-server/dark/oauth2-server-settings.avif) +{% /only_dark %} +{% only_light %} +![Configuring scopes on the OAuth2 server](/images/docs/oauth-server/oauth2-server-settings.avif) +{% /only_light %} + +A project can define up to 100 scopes, each up to 128 characters. A client can only request scopes you have defined; requesting an unknown scope fails the authorization request. + +Custom scopes are labels the OAuth2 server carries through the flow and stamps onto the access token. Enforcing what each scope allows is your resource server's job: read the `scope` claim from the access token (or from an [introspection](/docs/partners/oauth-server/tokens#introspect) response) and allow or deny the request accordingly. + +# Requesting scopes {% #requesting %} + +{% only_dark %} +![A scope travels from the client's request through consent and the access token to the resource server's check](/images/docs/oauth-server/dark/diagram-scopes.avif) +{% /only_dark %} +{% only_light %} +![A scope travels from the client's request through consent and the access token to the resource server's check](/images/docs/oauth-server/diagram-scopes.avif) +{% /only_light %} + +A client asks for scopes in the space-separated `scope` parameter when it starts authorization, for example `openid profile games.read`. See [Authorization](/docs/partners/oauth-server/authorization#authorize) for the authorize request and SDK examples. + +Your consent screen shows the requested scopes and can approve a subset. The access token and token response contain the scopes that were granted, so the client can detect when it received less access than it requested. Clients should request only the permissions the integration needs. + +# Rich authorization requests {% #rich-authorization-requests %} + +A scope is a flat permission such as `tasks.read`. It tells your API what the client may do, but it cannot identify which projects the permission applies to. Rich authorization requests (RAR, [RFC 9396](https://datatracker.ietf.org/doc/html/rfc9396)) add those structured details. + +Keep scopes as the primary permission contract for third-party clients. Use authorization details to bind those permissions to resources selected during consent. With this model, a client can request `tasks.read` without knowing a project ID, and your consent screen can ask the user which projects to grant. + +## Define accepted types {% #define-authorization-details-types %} + +The OAuth2 server accepts only the authorization detail types configured in `authorizationDetailsTypes`. For project-level restrictions, include `project`. A `project` entry contains a non-empty `identifiers` array with project IDs, or `*` to represent every project. + +Configure the accepted types when you update the OAuth2 server: + +{% multicode %} +```server-nodejs +import { Client, Project } from 'node-appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject('') // Your project ID + .setKey(''); // Your secret API key + +const project = new Project(client); + +const result = await project.updateOAuth2Server({ + enabled: true, + authorizationUrl: 'https://your-product.com/oauth/consent', + scopes: ['tasks.read', 'tasks.write'], + authorizationDetailsTypes: ['project'], +}); +``` +```server-php +setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + ->setProject('') // Your project ID + ->setKey(''); // Your secret API key + +$project = new Project($client); + +$result = $project->updateOAuth2Server( + enabled: true, + authorizationUrl: 'https://your-product.com/oauth/consent', + scopes: ['tasks.read', 'tasks.write'], + authorizationDetailsTypes: ['project'] +); +``` +```server-python +from appwrite.client import Client +from appwrite.services.project import Project + +client = Client() +client.set_endpoint('https://.cloud.appwrite.io/v1') # Your API Endpoint +client.set_project('') # Your project ID +client.set_key('') # Your secret API key + +project = Project(client) + +result = project.update_o_auth2_server( + enabled = True, + authorization_url = 'https://your-product.com/oauth/consent', + scopes = ['tasks.read', 'tasks.write'], + authorization_details_types = ['project'] +) +``` +```server-ruby +require 'appwrite' + +include Appwrite + +client = Client.new + .set_endpoint('https://.cloud.appwrite.io/v1') # Your API Endpoint + .set_project('') # Your project ID + .set_key('') # Your secret API key + +project = Project.new(client) + +result = project.update_o_auth2_server( + enabled: true, + authorization_url: 'https://your-product.com/oauth/consent', + scopes: ['tasks.read', 'tasks.write'], + authorization_details_types: ['project'] +) +``` +```server-dart +import 'package:dart_appwrite/dart_appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject('') // Your project ID + .setKey(''); // Your secret API key + +Project project = Project(client); + +Project result = await project.updateOAuth2Server( + enabled: true, + authorizationUrl: 'https://your-product.com/oauth/consent', + scopes: ['tasks.read', 'tasks.write'], + authorizationDetailsTypes: ['project'], +); +``` +```server-dotnet +using Appwrite; +using Appwrite.Models; +using Appwrite.Services; + +Client client = new Client() + .SetEndPoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .SetProject("") // Your project ID + .SetKey(""); // Your secret API key + +Project project = new Project(client); + +Project result = await project.UpdateOAuth2Server( + enabled: true, + authorizationUrl: "https://your-product.com/oauth/consent", + scopes: new List { "tasks.read", "tasks.write" }, + authorizationDetailsTypes: new List { "project" } +); +``` +```server-kotlin +import io.appwrite.Client +import io.appwrite.services.Project + +val client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + .setKey("") // Your secret API key + +val project = Project(client) + +val response = project.updateOAuth2Server( + enabled = true, + authorizationUrl = "https://your-product.com/oauth/consent", + scopes = listOf("tasks.read", "tasks.write"), + authorizationDetailsTypes = listOf("project") +) +``` +```server-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Project; + +Client client = new Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + .setKey(""); // Your secret API key + +Project project = new Project(client); + +project.updateOAuth2Server( + true, // enabled + "https://your-product.com/oauth/consent", // authorizationUrl + List.of("tasks.read", "tasks.write"), // scopes (optional) + List.of("project"), // authorizationDetailsTypes (optional) + null, // accessTokenDuration (optional) + null, // refreshTokenDuration (optional) + null, // publicAccessTokenDuration (optional) + null, // publicRefreshTokenDuration (optional) + null, // confidentialPkce (optional) + null, // verificationUrl (optional) + null, // userCodeLength (optional) + null, // userCodeFormat (optional) + null, // deviceCodeDuration (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + System.out.println(result); + }) +); +``` +```server-swift +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + .setKey("") // Your secret API key + +let project = Project(client) + +let result = try await project.updateOAuth2Server( + enabled: true, + authorizationUrl: "https://your-product.com/oauth/consent", + scopes: ["tasks.read", "tasks.write"], + authorizationDetailsTypes: ["project"] +) +``` +```server-go +package main + +import ( + "fmt" + "github.com/appwrite/sdk-for-go/client" + "github.com/appwrite/sdk-for-go/project" +) + +func main() { + c := client.New( + client.WithEndpoint("https://.cloud.appwrite.io/v1"), + client.WithProject(""), + client.WithKey(""), + ) + + service := project.New(c) + + response, err := service.UpdateOAuth2Server( + true, + "https://your-product.com/oauth/consent", + project.WithUpdateOAuth2ServerScopes([]string{"tasks.read", "tasks.write"}), + project.WithUpdateOAuth2ServerAuthorizationDetailsTypes([]string{"project"}), + ) + if err != nil { + panic(err) + } + + fmt.Println(response) +} +``` +```server-rust +use appwrite::client::Client; +use appwrite::services::project::Project; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let client = Client::new() + .set_endpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .set_project("") // Your project ID + .set_key(""); // Your secret API key + + let project = Project::new(&client); + + let result = project + .update_o_auth2_server( + true, + "https://your-product.com/oauth/consent", + Some(vec!["tasks.read".into(), "tasks.write".into()]), + Some(vec!["project".into()]), // authorization details types + None, + None, + None, + None, + None, + None, + None, + None, + None, + ) + .await?; + + let _ = result; + + Ok(()) +} +``` +{% /multicode %} + +{% info title="The call replaces the whole configuration" %} +`updateOAuth2Server` sets the full OAuth2 server configuration in one call. Include your existing scopes and settings when adding types, or they reset to their defaults. +{% /info %} + +Like scopes, a project can define up to 100 types, each up to 128 characters. The accepted types are published in the [discovery document](/docs/partners/oauth-server/quick-start#discovery) under `authorization_details_types_supported`. + +## Request authorization details {% #request-authorization-details %} + +Most clients should request scopes without sending `authorization_details`. Your consent screen can read the requested scopes, ask the user to select resources, and add the details when it approves the grant. This keeps project IDs and resource-selection logic out of the third-party integration. + +For example, after a client requests `tasks.read`, the user might choose one project. The consent screen approves the grant with the selected project in `authorization_details`: + +```bash +curl --request POST \ + 'https://.cloud.appwrite.io/v1/oauth2//approve' \ + --header 'Accept: application/json' \ + --header 'Cookie: a_session_=' \ + --form 'grant_id=' \ + --form 'authorization_details=[ + { + "type": "project", + "identifiers": [""] + } + ]' +``` + +In a browser-based consent screen, the project session cookie is sent automatically, so the `Cookie` header is not needed. The JSON response contains the client's redirect URL. Send the browser to that URL to continue the authorization flow. + +A client that already knows the relevant resource IDs may send the same structure on its [authorize request](/docs/partners/oauth-server/authorization#authorize). Treat that as an optional preselection. The authorization flow should also work when the client omits it, and the consent screen should let the user review or narrow the final resource selection. + +For the `project` type, Appwrite accepts only `type` and a non-empty `identifiers` array. Each identifier must be a project ID, or `*` for every project. Other values fail validation before the grant is approved. + +## Consent and enforcement {% #authorization-details-consent %} + +Scopes and authorization details answer different questions throughout the flow: + +1. The client requests capabilities such as `tasks.read`. +2. Your consent screen reads the grant and asks the user which projects those capabilities should cover. +3. The consent screen approves the grant with the selected `authorization_details`. +4. Appwrite returns the granted details in the token response, includes them in the access token, and returns them from token introspection. + +Your resource server must enforce both parts. After [introspecting the access token](/docs/partners/oauth-server/tokens#introspect), check that `active` is `true`, require the scope needed by the API operation, and then require an authorization detail that covers the requested resource. For an endpoint that reads tasks from one project, require `tasks.read` and a `project` entry whose `identifiers` contains that project ID or `*`. + +Apply these checks conservatively: + +- Treat a missing scope, missing authorization detail, unknown type, or unmatched identifier as denied. +- Interpret `*` explicitly as every project. Decide whether that includes projects created later and state that behavior on the consent screen. +- If `admin` grants every operation, document whether it also bypasses resource restrictions. Keep that rule consistent across every API endpoint. +- Compare stable resource IDs, not display names, and remove duplicate identifiers before evaluating them. + +Appwrite validates and carries the authorization details. Your consent screen decides what the user grants, and your resource server decides whether the granted scopes and details cover each API request. diff --git a/src/routes/docs/partners/oauth-server/sign-in-with-your-product/+layout.svelte b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/+layout.svelte new file mode 100644 index 0000000000..c1c9c5a8cb --- /dev/null +++ b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/+layout.svelte @@ -0,0 +1,10 @@ + + +{@render children()} diff --git a/src/routes/docs/partners/oauth-server/sign-in-with-your-product/+layout.ts b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/+layout.ts new file mode 100644 index 0000000000..c8884f46b8 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/+layout.ts @@ -0,0 +1,11 @@ +import type { LayoutLoad } from './$types'; + +export const load: LayoutLoad = ({ url }) => { + const tutorials = import.meta.glob('./**/*.markdoc', { + eager: true + }); + return { + tutorials, + pathname: url.pathname + }; +}; diff --git a/src/routes/docs/partners/oauth-server/sign-in-with-your-product/+page.ts b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/+page.ts new file mode 100644 index 0000000000..deeb96a18b --- /dev/null +++ b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/+page.ts @@ -0,0 +1,5 @@ +import { redirect } from '@sveltejs/kit'; + +export function load() { + redirect(303, '/docs/partners/oauth-server/sign-in-with-your-product/step-1'); +} diff --git a/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-1/+page.markdoc b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-1/+page.markdoc new file mode 100644 index 0000000000..527c77a5f1 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-1/+page.markdoc @@ -0,0 +1,50 @@ +--- +layout: tutorial +title: Sign in with your product +description: Build an end-to-end "Sign in with your product" experience against your Appwrite OAuth2 server, from the consent screen to the token exchange. +step: 1 +difficulty: intermediate +readtime: 25 +framework: TanStack Start +category: OAuth2 server +back: /docs/partners/oauth-server +--- + +Once your project's [OAuth2 server](/docs/partners/oauth-server) is enabled, other apps can offer "Sign in with your product". This tutorial builds that experience end to end with two small [TanStack Start](https://tanstack.com/start) apps, so you can see every moving part. + +# What you will build {% #what-you-will-build %} + +Two apps play the two sides of an OAuth integration: + +- **TaskFlow**, the provider. It owns the Appwrite project with the OAuth2 server enabled, and it hosts the **consent screen** where its users approve access. +- **Vantage**, the consumer. A separate product that adds a **Sign in with TaskFlow** button, exchanges the authorization code for tokens on its server, and reads the user's profile. + +TaskFlow is your product. Vantage stands in for any third party integrating with it. + +# The flow {% #the-flow %} + +When a Vantage user clicks **Sign in with TaskFlow**, this happens: + +1. Vantage redirects the user to TaskFlow's **authorize** endpoint. +2. The OAuth2 server sends the user to TaskFlow's **consent screen** to sign in and approve. +3. On approval, the server redirects back to Vantage with an **authorization code**. +4. Vantage's server **exchanges the code for tokens** using its client secret. +5. Vantage reads the user's profile from **userinfo** and signs them in. + +The authorization code flow is standard OAuth 2.1, so nothing here is Appwrite-specific on the wire. The two pieces you build are the consent screen TaskFlow hosts and the sign-in Vantage adds. + +# Why the token exchange runs on the server {% #server-side %} + +Vantage is a **confidential client**: it has a client secret. That secret authenticates the token exchange and must never reach the browser. TanStack Start makes this natural, the exchange runs inside a **server function**, so the secret stays on the server the whole time. + +# Prerequisites {% #prerequisites %} + +- An Appwrite Cloud project. +- [Node.js](https://nodejs.org/) 20 or newer and a package manager (this tutorial uses `pnpm`). +- Basic familiarity with React. + +{% info title="Get the finished code" %} +The complete apps from this tutorial are on GitHub at [appwrite-community/oauth-guide-taskflow](https://github.com/appwrite-community/oauth-guide-taskflow). Clone it to follow along or to compare against your own. +{% /info %} + +Continue to enable the OAuth2 server on your project. diff --git a/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-2/+page.markdoc b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-2/+page.markdoc new file mode 100644 index 0000000000..06ac4fa880 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-2/+page.markdoc @@ -0,0 +1,55 @@ +--- +layout: tutorial +title: Enable the OAuth2 server +description: Turn on the OAuth2 server on your Appwrite project and register the client app. +step: 2 +--- + +Before writing any code, turn TaskFlow's project into an OAuth provider and register Vantage as a client. + +# Enable the server {% #enable %} + +{% only_dark %} +![Enabling the OAuth2 server in the Appwrite Console](/images/docs/oauth-server/dark/oauth2-server-settings.avif) +{% /only_dark %} +{% only_light %} +![Enabling the OAuth2 server in the Appwrite Console](/images/docs/oauth-server/oauth2-server-settings.avif) +{% /only_light %} + +In the Console, open **Auth**, select the **OAuth2 server** tab, and turn on **Enable OAuth2 server**. + +Set the **Authorization URL** to where TaskFlow will host its consent screen. In this tutorial that is `http://localhost:4000/oauth/consent`. This is where the OAuth2 server sends users to sign in and approve. + +Leave the scopes at their defaults. `openid`, `profile`, and `email` are always included, which is all Vantage needs. See [Scopes](/docs/partners/oauth-server/scopes) to add your own later. + +# Register the client {% #register-client %} + +{% only_dark %} +![Creating the Vantage client in the Appwrite Console](/images/docs/oauth-server/dark/oauth2-server-create-app.avif) +{% /only_dark %} +{% only_light %} +![Creating the Vantage client in the Appwrite Console](/images/docs/oauth-server/oauth2-server-create-app.avif) +{% /only_light %} + +Open the **Apps** sub-tab and create a client for Vantage: + +- **Name**: `Vantage`. This appears on the consent screen. +- **Client type**: `Confidential`. Vantage has a server that can hold a secret. +- **Redirect URIs**: `http://localhost:4100/oauth/callback`. The OAuth2 server only returns codes to registered URIs. + +When you create the client, copy the **client secret**. It is shown once. You will also need the **client ID** from the clients list. + +{% only_dark %} +![The client secret shown once on creation](/images/docs/oauth-server/dark/oauth2-server-secret-created.avif) +{% /only_dark %} +{% only_light %} +![The client secret shown once on creation](/images/docs/oauth-server/oauth2-server-secret-created.avif) +{% /only_light %} + +{% info title="Keep these three values" %} +You now have everything the apps need to talk to the OAuth2 server: the **client ID**, the **client secret**, and your **project ID**. Keep them handy for the next step. +{% /info %} + +For the full set of client options and the SDK equivalents, see [Clients](/docs/partners/oauth-server/clients). + +Continue to scaffold the two apps. diff --git a/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-3/+page.markdoc b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-3/+page.markdoc new file mode 100644 index 0000000000..6dc848baf8 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-3/+page.markdoc @@ -0,0 +1,74 @@ +--- +layout: tutorial +title: Create the apps +description: Scaffold the two TanStack Start apps and wire up their environment. +step: 3 +--- + +Both sides are TanStack Start apps. Scaffold them in a single folder. + +# Scaffold the projects {% #scaffold %} + +Create the consumer (Vantage) and the provider (TaskFlow): + +```sh +npx @tanstack/cli create consumer --framework React --package-manager pnpm --no-examples +npx @tanstack/cli create provider --framework React --package-manager pnpm --no-examples +``` + +This gives you two full TanStack Start apps with server functions, file-based routing, and Tailwind CSS already set up. + +Give each a fixed port so the redirect URIs stay stable. In each app's `package.json`, set the dev script: + +```json +// consumer/package.json +"scripts": { "dev": "vite dev --port 4100" } +``` + +```json +// provider/package.json +"scripts": { "dev": "vite dev --port 4000" } +``` + +# Configure the environment {% #env %} + +The apps read the OAuth values from environment variables. Add a `.env` to each. + +Vantage needs the client credentials and its redirect URI: + +```sh +# consumer/.env +OAUTH_ISSUER=https://.cloud.appwrite.io/v1/oauth2/ +OAUTH_CLIENT_ID= +OAUTH_CLIENT_SECRET= +OAUTH_REDIRECT_URI=http://localhost:4100/oauth/callback +SESSION_SECRET= +``` + +TaskFlow needs its project details and an API key (used only to read a client's display name for the consent card): + +```sh +# provider/.env +APPWRITE_ENDPOINT=https://.cloud.appwrite.io/v1 +APPWRITE_PROJECT= +OAUTH_ISSUER=https://.cloud.appwrite.io/v1/oauth2/ +APPWRITE_API_KEY= +SESSION_SECRET= +``` + +Vite only exposes variables prefixed with `VITE_` to the browser, and these are secrets, so load them into the server with `dotenv`. Install it in both apps: + +```sh +pnpm add dotenv +``` + +Then import it at the top of each `vite.config.ts`, before anything else, so `process.env` is populated when the server runs: + +```ts +// vite.config.ts +import 'dotenv/config' +import { defineConfig } from 'vite' +// ...rest of the config +``` + +With both apps scaffolded and configured, build Vantage's sign-in next. diff --git a/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-4/+page.markdoc b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-4/+page.markdoc new file mode 100644 index 0000000000..f8cb982b4f --- /dev/null +++ b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-4/+page.markdoc @@ -0,0 +1,86 @@ +--- +layout: tutorial +title: Add Sign in with your product +description: Build the consumer's sign-in button and the redirect that starts the OAuth flow. +step: 4 +--- + +Start with Vantage, the consumer. It needs a helper for the OAuth values, a landing page with a **Sign in with TaskFlow** button, and a route that kicks off the flow. + +# The OAuth helper {% #helper %} + +Create `consumer/src/lib/oauth.ts`. It reads the config and builds the authorization URL. Import `@tanstack/react-start/server-only` at the top: it makes the build fail if this module is ever pulled into the browser bundle, which keeps the client secret server-side. + +```ts +// consumer/src/lib/oauth.ts +import '@tanstack/react-start/server-only' +import { useSession } from '@tanstack/react-start/server' + +const issuer = process.env.OAUTH_ISSUER! +const clientId = process.env.OAUTH_CLIENT_ID! +const redirectUri = process.env.OAUTH_REDIRECT_URI! + +export const SCOPES = 'openid profile email' + +/** Build the URL that starts the authorization code flow. */ +export function authorizeUrl(state: string) { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: SCOPES, + state, + }) + return `${issuer}/authorize?${params.toString()}` +} + +type SessionData = { accessToken?: string; user?: unknown; state?: string } + +/** A signed, httpOnly cookie session for Vantage. */ +export function vantageSession() { + return useSession({ + name: 'vantage_session', + password: process.env.SESSION_SECRET!, + }) +} +``` + +# The start route {% #start-route %} + +Clicking the button navigates to `/oauth/start`. Its loader mints a random `state` value, stores it in the session to protect against CSRF, and redirects to the OAuth2 server. Create `consumer/src/routes/oauth.start.tsx`: + +```tsx +// consumer/src/routes/oauth.start.tsx +import { createFileRoute, redirect } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { authorizeUrl, vantageSession } from '../lib/oauth' + +const start = createServerFn().handler(async () => { + const state = crypto.randomUUID() + const session = await vantageSession() + await session.update({ state }) + throw redirect({ href: authorizeUrl(state) }) +}) + +export const Route = createFileRoute('/oauth/start')({ + loader: async () => { + await start() + }, + component: () => null, +}) +``` + +# The sign-in button {% #button %} + +![Vantage landing page with a Sign in with TaskFlow button](/images/docs/oauth-server/guide/vantage-landing.avif) + +On the landing page, the button is a link to `/oauth/start`: + +```tsx +// consumer/src/routes/index.tsx (excerpt) + + Sign in with TaskFlow + +``` + +Start the app with `pnpm dev` and open `http://localhost:4100`. You have a landing page with a working sign-in button. Clicking it redirects to the OAuth2 server, which sends the user to TaskFlow's consent screen. That screen does not exist yet, so build it next. diff --git a/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-5/+page.markdoc b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-5/+page.markdoc new file mode 100644 index 0000000000..fa6c2766cc --- /dev/null +++ b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-5/+page.markdoc @@ -0,0 +1,219 @@ +--- +layout: tutorial +title: Build the consent screen +description: Host the consent screen where your users sign in and approve access. +step: 5 +--- + +The consent screen is the page TaskFlow hosts at its authorization URL. When the OAuth2 server sends a user here, the screen signs them in, shows what the client is asking for, and records their decision. All of it runs on TaskFlow's server, carrying the user's Appwrite session. + +# Types for the consent card {% #types %} + +Create `provider/src/lib/consent-types.ts`. It holds only client-safe values, so the browser can import it: + +```ts +// provider/src/lib/consent-types.ts +export type Grant = { + $id: string + appId: string + scopes: string[] + redirectUri: string +} + +export type ClientApp = { $id: string; name: string; tagline?: string } + +// Human-readable labels for the scopes shown on the consent card. +export const SCOPE_LABELS: Record = { + openid: 'Confirm your identity', + profile: 'See your name and profile details', + email: 'See your email address', + phone: 'See your phone number', +} +``` + +# The server helpers {% #helpers %} + +Create `provider/src/lib/oauth-server.ts`. Every function here calls the OAuth2 server on behalf of the signed-in user. Appwrite returns the user's session token in a cookie on login, and that token is passed as the `X-Appwrite-Session` header on later calls. + +```ts +// provider/src/lib/oauth-server.ts +import '@tanstack/react-start/server-only' +import { useSession } from '@tanstack/react-start/server' +import type { ClientApp, Grant } from './consent-types' + +const endpoint = process.env.APPWRITE_ENDPOINT! +const project = process.env.APPWRITE_PROJECT! +const issuer = process.env.OAUTH_ISSUER! +const apiKey = process.env.APPWRITE_API_KEY! + +const projectHeaders = { 'X-Appwrite-Project': project } + +/** Log a TaskFlow user in and return their Appwrite session token. */ +export async function login(email: string, password: string): Promise { + const res = await fetch(`${endpoint}/account/sessions/email`, { + method: 'POST', + headers: { ...projectHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + redirect: 'manual', + }) + if (!res.ok) throw new Error('Invalid email or password') + const setCookie = res.headers.get('set-cookie') ?? '' + const match = setCookie.match(new RegExp(`a_session_${project}=([^;]+)`)) + if (!match) throw new Error('No session returned') + return match[1] +} + +type AuthorizeParams = Record + +/** Create a grant for the signed-in user, or get a redirect if the OAuth + * server auto-approved a request the user already consented to. */ +export async function authorize( + params: AuthorizeParams, + sessionToken: string, +): Promise<{ grantId?: string; redirect?: string }> { + const qs = new URLSearchParams(params).toString() + const res = await fetch(`${issuer}/authorize?${qs}`, { + headers: { ...projectHeaders, 'X-Appwrite-Session': sessionToken }, + redirect: 'manual', + }) + const location = res.headers.get('location') ?? '' + const grantId = new URL(location, endpoint).searchParams.get('grant_id') + return grantId ? { grantId } : { redirect: location } +} + +export async function getGrant(grantId: string, sessionToken: string): Promise { + const res = await fetch(`${issuer}/grants/${grantId}`, { + headers: { ...projectHeaders, 'X-Appwrite-Session': sessionToken }, + }) + if (!res.ok) throw new Error(`Grant not found: ${res.status}`) + return res.json() +} + +/** Read a client's display name for the consent card, using a server-side key. */ +export async function getClientApp(appId: string): Promise { + const res = await fetch(`${endpoint}/apps/${appId}`, { + headers: { ...projectHeaders, 'X-Appwrite-Key': apiKey }, + }) + if (!res.ok) throw new Error(`App not found: ${res.status}`) + return res.json() +} + +/** Approve or reject a grant. The OAuth2 server returns the URL to send the + * user back to, carrying the authorization code (approve) or an error (reject). */ +async function decide( + action: 'approve' | 'reject', + grantId: string, + sessionToken: string, +): Promise { + const res = await fetch(`${issuer}/${action}`, { + method: 'POST', + headers: { + ...projectHeaders, + 'X-Appwrite-Session': sessionToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ grant_id: grantId }), + redirect: 'manual', + }) + const location = res.headers.get('location') + if (!location) throw new Error(`${action} failed: ${res.status}`) + return location +} + +export const approve = (grantId: string, token: string) => decide('approve', grantId, token) +export const reject = (grantId: string, token: string) => decide('reject', grantId, token) + +type SessionData = { token?: string; email?: string; params?: AuthorizeParams } + +/** TaskFlow's own signed session cookie, holding the Appwrite session token. */ +export function taskflowSession() { + return useSession({ + name: 'taskflow_session', + password: process.env.SESSION_SECRET!, + }) +} +``` + +# The consent route {% #route %} + +Create `provider/src/routes/oauth.consent.tsx`. The loader decides what to show: the login form if the user is not signed in, or the consent card once they are. It captures the incoming authorize parameters so it can resume the request after login. + +```tsx +// provider/src/routes/oauth.consent.tsx (loader and server functions) +import { createFileRoute, redirect } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { getRequestUrl } from '@tanstack/react-start/server' +import { + approve, authorize, getClientApp, getGrant, login, reject, taskflowSession, +} from '../lib/oauth-server' +import type { ClientApp, Grant } from '../lib/consent-types' + +const AUTHORIZE_KEYS = ['client_id', 'redirect_uri', 'response_type', 'scope', 'state', 'nonce'] + +type ConsentView = + | { view: 'login' } + | { view: 'consent'; grant: Grant; app: ClientApp; email: string } + +const loadConsent = createServerFn().handler(async (): Promise => { + const url = getRequestUrl() + const session = await taskflowSession() + + // Capture the authorize request so we can resume it after login. + const incoming: Record = {} + for (const key of AUTHORIZE_KEYS) { + const value = url.searchParams.get(key) + if (value) incoming[key] = value + } + + if (!session.data.token) { + if (Object.keys(incoming).length) await session.update({ params: incoming }) + return { view: 'login' } + } + + let grantId = url.searchParams.get('grant_id') + if (!grantId) { + const params = { ...session.data.params, ...incoming } + const result = await authorize(params, session.data.token) + if (result.redirect) throw redirect({ href: result.redirect }) + grantId = result.grantId! + } + + const grant = await getGrant(grantId, session.data.token) + const app = await getClientApp(grant.appId) + return { view: 'consent', grant, app, email: session.data.email ?? '' } +}) + +const submitLogin = createServerFn({ method: 'POST' }) + .validator((d: { email: string; password: string }) => d) + .handler(async ({ data }) => { + const token = await login(data.email, data.password) + const session = await taskflowSession() + await session.update({ token, email: data.email }) + }) + +const decideGrant = createServerFn({ method: 'POST' }) + .validator((d: { grantId: string; action: 'approve' | 'reject' }) => d) + .handler(async ({ data }) => { + const session = await taskflowSession() + const act = data.action === 'approve' ? approve : reject + const location = await act(data.grantId, session.data.token!) + throw redirect({ href: location }) + }) + +export const Route = createFileRoute('/oauth/consent')({ + component: Consent, + loader: async () => loadConsent(), +}) +``` + +The component renders one of two cards from the loader data. The login form submits `submitLogin` and then calls `router.invalidate()` so the loader re-runs and moves to the consent view. The consent card reads `grant.scopes` and maps them through `SCOPE_LABELS`, and calls `decideGrant` on **Authorize** or **Cancel**. Only the styling is left out here. + +# Sign in and approve {% #sign-in %} + +![TaskFlow consent screen sign-in form](/images/docs/oauth-server/guide/taskflow-login.avif) + +When the OAuth2 server sends a user to the consent screen, they first sign in with their TaskFlow account. + +![TaskFlow consent screen showing the requested permissions](/images/docs/oauth-server/guide/taskflow-consent.avif) + +The consent card then shows the client's name and exactly what it is asking for. On **Authorize**, the OAuth2 server redirects back to Vantage with an authorization code. Vantage handles it next. diff --git a/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-6/+page.markdoc b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-6/+page.markdoc new file mode 100644 index 0000000000..38d65716cb --- /dev/null +++ b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-6/+page.markdoc @@ -0,0 +1,130 @@ +--- +layout: tutorial +title: Exchange the code for tokens +description: Handle the callback, exchange the authorization code for tokens on the server, and sign the user in. +step: 6 +--- + +The OAuth2 server redirects back to Vantage's redirect URI with a `code` and the `state`. Vantage exchanges that code for tokens on its server, reads the user's profile, and signs them in. + +# Add the token functions {% #token-functions %} + +Extend `consumer/src/lib/oauth.ts` with the exchange and userinfo calls. The exchange authenticates with the client secret using HTTP Basic auth, which is why it must run on the server. + +```ts +// consumer/src/lib/oauth.ts (additions) +const clientSecret = process.env.OAUTH_CLIENT_SECRET! + +export type Tokens = { + access_token: string + refresh_token: string + id_token: string + expires_in: number + scope: string +} + +/** Exchange an authorization code for tokens. */ +export async function exchangeCode(code: string): Promise { + const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64') + const res = await fetch(`${issuer}/token`, { + method: 'POST', + headers: { + Authorization: `Basic ${basic}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + }), + }) + if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`) + return res.json() +} + +export type UserInfo = { + sub: string + name?: string + email?: string + email_verified?: boolean +} + +/** Read the signed-in user's profile from the userinfo endpoint. */ +export async function fetchUserInfo(accessToken: string): Promise { + const res = await fetch(`${issuer}/userinfo`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (!res.ok) throw new Error(`userinfo failed: ${res.status}`) + return res.json() +} +``` + +# Handle the callback {% #callback %} + +Create `consumer/src/routes/oauth.callback.tsx`. Its loader runs on the server: it checks the `state` against the session, exchanges the code, reads the profile, stores it in the session, and sends the user to the dashboard. + +```tsx +// consumer/src/routes/oauth.callback.tsx +import { createFileRoute, redirect } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { getRequestUrl } from '@tanstack/react-start/server' +import { exchangeCode, fetchUserInfo, vantageSession } from '../lib/oauth' + +const handleCallback = createServerFn().handler(async () => { + const url = getRequestUrl() + const code = url.searchParams.get('code') + const state = url.searchParams.get('state') + const session = await vantageSession() + + // The state must match the value we set in /oauth/start. + if (!code || !state || state !== session.data.state) { + throw redirect({ to: '/', search: { error: 'invalid_state' } }) + } + + const tokens = await exchangeCode(code) + const user = await fetchUserInfo(tokens.access_token) + + await session.update({ + accessToken: tokens.access_token, + user, + state: undefined, + }) + + throw redirect({ to: '/dashboard' }) +}) + +export const Route = createFileRoute('/oauth/callback')({ + loader: async () => { + await handleCallback() + }, + component: () => null, +}) +``` + +The client secret and the tokens live only inside these server functions. The browser only ever holds Vantage's own signed session cookie. + +# Show the signed-in user {% #dashboard %} + +![Vantage dashboard showing the signed-in user's TaskFlow identity](/images/docs/oauth-server/guide/vantage-dashboard.avif) + +The dashboard reads the user from the session and renders it. Create `consumer/src/routes/dashboard.tsx`: + +```tsx +// consumer/src/routes/dashboard.tsx (loader) +import { createFileRoute, redirect } from '@tanstack/react-router' +import { createServerFn } from '@tanstack/react-start' +import { vantageSession, type UserInfo } from '../lib/oauth' + +const loadUser = createServerFn().handler(async (): Promise => { + const session = await vantageSession() + if (!session.data.user) throw redirect({ to: '/' }) + return session.data.user as UserInfo +}) + +export const Route = createFileRoute('/dashboard')({ + component: Dashboard, + loader: async () => ({ user: await loadUser() }), +}) +``` + +The component renders `user.name`, `user.email`, and `user.sub` from the loader data. That profile came from TaskFlow, through the authorization code flow you built. Run the whole thing next. diff --git a/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-7/+page.markdoc b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-7/+page.markdoc new file mode 100644 index 0000000000..be0e5d5ec7 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/sign-in-with-your-product/step-7/+page.markdoc @@ -0,0 +1,57 @@ +--- +layout: tutorial +title: Run the flow +description: Start both apps and sign in with your product end to end. +step: 7 +--- + +Everything is in place. Run both apps and sign in. + +# Start both apps {% #start %} + +In two terminals: + +```sh +# in consumer/ +pnpm dev +``` + +```sh +# in provider/ +pnpm dev +``` + +Vantage is at `http://localhost:4100` and TaskFlow's consent screen at `http://localhost:4000`. + +# Sign in {% #sign-in %} + +Open `http://localhost:4100` and click **Sign in with TaskFlow**. You will: + +1. Land on TaskFlow's consent screen and sign in with a TaskFlow user. +2. See exactly what Vantage is requesting, and approve it. +3. Return to Vantage, signed in, with your TaskFlow name and email on the dashboard. + +That round trip is a complete OAuth 2.1 authorization code flow against your project's OAuth2 server. + +# What each side did {% #recap %} + +- **TaskFlow** enabled the OAuth2 server, registered Vantage as a confidential client, and hosted a consent screen that authenticates its users and approves grants. +- **Vantage** redirected to the authorize endpoint, exchanged the returned code for tokens on its server, and read the user's profile from userinfo. The client secret never left its server. + +Because the flow is standards-based, a real integrator does not have to use Appwrite or TanStack Start. Any OAuth or OpenID Connect client library pointed at your project's [discovery URL](/docs/partners/oauth-server/quick-start#discovery) works the same way. + +The full source for both apps is on GitHub at [appwrite-community/oauth-guide-taskflow](https://github.com/appwrite-community/oauth-guide-taskflow). + +# Next steps {% #next-steps %} + +{% cards %} +{% cards_item href="/docs/partners/oauth-server/tokens" title="Tokens" %} +Refresh access tokens, and revoke them on sign-out. +{% /cards_item %} +{% cards_item href="/docs/partners/oauth-server/scopes" title="Scopes" %} +Add custom scopes to represent permissions in your product. +{% /cards_item %} +{% cards_item href="/docs/partners/oauth-server/device-flow" title="Device flow" %} +Support TVs, CLIs, and other input-constrained devices. +{% /cards_item %} +{% /cards %} diff --git a/src/routes/docs/partners/oauth-server/tokens/+page.markdoc b/src/routes/docs/partners/oauth-server/tokens/+page.markdoc new file mode 100644 index 0000000000..d20f16d6fa --- /dev/null +++ b/src/routes/docs/partners/oauth-server/tokens/+page.markdoc @@ -0,0 +1,436 @@ +--- +layout: article +title: Tokens +description: Access, refresh, and ID tokens issued by Appwrite's OAuth2 server, their lifetimes, and how to validate, refresh, introspect, revoke, and end sessions. +back: /docs/partners/oauth-server +--- + +When a client redeems an authorization code, the OAuth2 server issues an access token and refresh token. It also issues an ID token when the `openid` scope was granted. This page covers what each token does and how clients and resource servers validate, refresh, introspect, revoke, and end sessions. + +# The three tokens {% #tokens %} + +{% only_dark %} +![The token endpoint issues access, refresh, and ID tokens, with refresh rotation and revocation](/images/docs/oauth-server/dark/diagram-tokens.avif) +{% /only_dark %} +{% only_light %} +![The token endpoint issues access, refresh, and ID tokens, with refresh rotation and revocation](/images/docs/oauth-server/diagram-tokens.avif) +{% /only_light %} + +- **Access token.** A signed JWT that a client presents to a resource server when it calls an API on the user's behalf. It contains the authorization information the resource server needs to evaluate the request. The claims are explained in [Validate tokens](#validate). +- **Refresh token.** A value the client exchanges for a new access token when the current one expires, without sending the user through authorization again. Treat it as an opaque secret even though Appwrite currently encodes it as a JWT. +- **ID token.** A signed JWT from OpenID Connect that tells the client who authenticated. It is returned when the `openid` scope is granted and can include profile, email, or phone claims when those scopes were approved. A public client can use these verified claims to show the user's name or decide that its sign-in UI needs attention before calling an API. Resource servers authorize requests with the access token, not the ID token. + +# Exchange a code for tokens {% #exchange %} + +The client exchanges its authorization code at the token endpoint with `grantType: 'authorization_code'`. A confidential client sends its `clientSecret`; a public client sends its `codeVerifier` instead. + +{% multicode %} +```client-web +import { Client, Oauth2 } from 'appwrite'; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const oauth2 = new Oauth2(client); + +const result = await oauth2.createToken({ + grantType: '', + code: '', // optional + refreshToken: '', // optional + deviceCode: '', // optional + clientId: '', // optional + clientSecret: '', // optional + codeVerifier: '', // optional + redirectUri: 'https://example.com', // optional + resource: '' // optional +}); + +console.log(result); +``` +```client-flutter +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Oauth2 oauth2 = Oauth2(client); + +Oauth2Token result = await oauth2.createToken( + grantType: '', + code: '', // optional + refreshToken: '', // optional + deviceCode: '', // optional + clientId: '', // optional + clientSecret: '', // optional + codeVerifier: '', // optional + redirectUri: 'https://example.com', // optional + resource: '', // optional +); +``` +```client-apple +import Appwrite + +let client = Client() + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +let oauth2 = Oauth2(client) + +let oauth2Token = try await oauth2.createToken( + grant_type: "", + code: "", // optional + refresh_token: "", // optional + device_code: "", // optional + client_id: "", // optional + client_secret: "", // optional + code_verifier: "", // optional + redirect_uri: "https://example.com", // optional + resource: "" // optional +) +``` +```client-android-kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +import io.appwrite.services.Oauth2 + +val client = Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject("") // Your project ID + +val oauth2 = Oauth2(client) + +val result = oauth2.createToken( + grant_type = "", + code = "", // (optional) + refresh_token = "", // (optional) + device_code = "", // (optional) + client_id = "", // (optional) + client_secret = "", // (optional) + code_verifier = "", // (optional) + redirect_uri = "https://example.com", // (optional) + resource = "", // (optional) +) +``` +```client-android-java +import io.appwrite.Client; +import io.appwrite.coroutines.CoroutineCallback; +import io.appwrite.services.Oauth2; + +Client client = new Client(context) + .setEndpoint("https://.cloud.appwrite.io/v1") // Your API Endpoint + .setProject(""); // Your project ID + +Oauth2 oauth2 = new Oauth2(client); + +oauth2.createToken( + "", // grant_type + "", // code (optional) + "", // refresh_token (optional) + "", // device_code (optional) + "", // client_id (optional) + "", // client_secret (optional) + "", // code_verifier (optional) + "https://example.com", // redirect_uri (optional) + "", // resource (optional) + new CoroutineCallback<>((result, error) -> { + if (error != null) { + error.printStackTrace(); + return; + } + + Log.d("Appwrite", result.toString()); + }) +); +``` +```client-react-native +import { Client, Oauth2 } from "react-native-appwrite"; + +const client = new Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +const oauth2 = new Oauth2(client); + +const result = await oauth2.createToken({ + grantType: '', + code: '', // optional + refreshToken: '', // optional + deviceCode: '', // optional + clientId: '', // optional + clientSecret: '', // optional + codeVerifier: '', // optional + redirectUri: 'https://example.com', // optional + resource: '' // optional +}); + +console.log(result); +``` +{% /multicode %} + +The response includes the access token, refresh token, granted scopes, token type, and access-token lifetime. It includes an ID token when the `openid` scope was granted. `authorization_details` contains the approved rich authorization data when the request used it. + +```json +{ + "access_token": "eyJ...", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "eyJ...", + "scope": "openid profile email", + "authorization_details": null, + "id_token": "eyJ..." +} +``` + +# Token lifetimes {% #lifetimes %} + +Lifetimes default by client type and are configurable per project on the **OAuth2 server** settings. + +| | Confidential | Public | +| --- | --- | --- | +| Access token | 8 hours | 1 hour | +| Refresh token | 365 days | 30 days | + +Public clients get shorter lifetimes because their tokens live on user devices, where the risk of theft is higher, so the window of exposure is kept small. + +# Refresh with rotation {% #refresh %} + +To refresh, the client sends its current refresh token to the token endpoint. A successful response contains a new access token and a new refresh token. + +Each successful refresh replaces the stored refresh token. The client must store the newest refresh token before making another request. If the client presents an older refresh token again, Appwrite treats it as reuse and deletes the OAuth identity for that client and user. The current access token and newest refresh token then stop working, and the user must authorize that client again. Tokens issued to other clients are not affected. + +This public-client example sends JSON. A confidential client also includes its `client_secret`. + +{% multicode %} +```curl +curl -X POST 'https://.cloud.appwrite.io/v1/oauth2//token' \ + -H 'Content-Type: application/json' \ + -d '{ + "grant_type": "refresh_token", + "refresh_token": "", + "client_id": "" + }' +``` +```hurl +POST https://.cloud.appwrite.io/v1/oauth2//token +Content-Type: application/json +{ + "grant_type": "refresh_token", + "refresh_token": "", + "client_id": "" +} +``` +{% /multicode %} + +The response has the same shape as the original token response, with new values for `access_token`, `refresh_token`, and `expires_in`. + +# Validate tokens {% #validate %} + +Access and ID tokens are `RS256` JWTs signed with your project's key. A JWT contains a header, payload, and signature. The payload is readable, but its claims are trustworthy only after the client or resource server verifies the signature, issuer, audience, and expiry. + +The server publishes its public keys as a JWKS document: + +```text +https://.cloud.appwrite.io/v1/oauth2//.well-known/jwks.json +``` + +A web client can use the `jose` package to read the discovery document, select the correct public key from the JWKS response, and verify both tokens: + +```client-web +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +const accessToken = ''; +const idToken = ''; +const clientId = ''; + +const discoveryUrl = + 'https://.cloud.appwrite.io/v1/oauth2//.well-known/openid-configuration'; +const metadata = await fetch(discoveryUrl).then((response) => response.json()); +const jwks = createRemoteJWKSet(new URL(metadata.jwks_uri)); +const projectAudience = metadata.issuer.replace('/oauth2/', '/'); + +const { payload: accessClaims } = await jwtVerify(accessToken, jwks, { + issuer: metadata.issuer, + audience: projectAudience +}); + +const { payload: idClaims } = await jwtVerify(idToken, jwks, { + issuer: metadata.issuer, + audience: clientId +}); +``` + +A verified access-token payload contains the authorization context for API calls: + +```json +{ + "iss": "", + "sub": "", + "aud": [""], + "client_id": "", + "scope": "openid profile email calendar.read", + "auth_time": 1784052423, + "iat": 1784052858, + "exp": 1784056458, + "jti": "", + "tokenId": "" +} +``` + +A verified ID-token payload identifies the signed-in user. Profile claims appear only when their matching scopes were granted: + +```json +{ + "iss": "", + "sub": "", + "aud": "", + "name": "Ada Lovelace", + "email": "ada@example.com", + "email_verified": true, + "auth_time": 1784052423, + "iat": 1784052858, + "exp": 1784056458, + "at_hash": "" +} +``` + +A client may decode a token without verification to make a temporary UI choice, such as showing a sign-in screen before an API request. Only verified claims should control access or display trusted identity information. + +To retrieve the current user's profile with an access token, call the userinfo endpoint: + +{% multicode %} +```curl +curl 'https://.cloud.appwrite.io/v1/oauth2//userinfo' \ + -H 'Authorization: Bearer ' +``` +```hurl +GET https://.cloud.appwrite.io/v1/oauth2//userinfo +Authorization: Bearer +``` +{% /multicode %} + +# Introspect a token {% #introspect %} + +An OAuth access token is not an Appwrite session or API key, so it does not authorize general Appwrite SDK methods. Appwrite's OAuth endpoints consume tokens only where the OAuth flow defines them. For example, userinfo accepts an access token as a bearer credential, while the token, introspection, and revocation endpoints accept their respective tokens as request parameters. + +To make an access token useful to your product: + +1. Add custom scopes in **Auth > OAuth2 server > Settings** that describe the operations your API exposes. +2. Host an API that acts as the resource server. Appwrite Functions and server-rendered Appwrite Sites are suitable places to run it. +3. Read the bearer access token from each incoming request. +4. From your server, call the introspection endpoint with an Appwrite API key that has the `oauth2.read` scope. Keep this API key on the server. +5. Require `active: true`, then check that `scope` contains every permission the API operation needs. +6. Perform the operation only after those checks pass. + +Introspection verifies the token against the current OAuth identity, so it detects expiry, revocation, refresh rotation, and refresh-token reuse. This is different from offline JWT verification, which cannot detect a token that was revoked before its `exp` time. + +The resource server sends JSON: + +{% multicode %} +```curl +curl -X POST 'https://.cloud.appwrite.io/v1/oauth2//introspect' \ + -H 'X-Appwrite-Project: ' \ + -H 'X-Appwrite-Key: ' \ + -H 'Content-Type: application/json' \ + -d '{ + "token": "", + "token_type_hint": "access_token" + }' +``` +```hurl +POST https://.cloud.appwrite.io/v1/oauth2//introspect +X-Appwrite-Project: +X-Appwrite-Key: +Content-Type: application/json +{ + "token": "", + "token_type_hint": "access_token" +} +``` +{% /multicode %} + +An active access token returns its client, user, audience, expiry, and granted scopes: + +```json +{ + "active": true, + "scope": "calendar.read calendar.write", + "client_id": "", + "token_type": "Bearer", + "sub": "", + "aud": [""], + "iss": "", + "exp": 1784056458, + "iat": 1784052858, + "jti": "", + "token_use": "access_token" +} +``` + +An expired, revoked, malformed, or otherwise inactive token returns: + +```json +{ + "active": false +} +``` + +A confidential OAuth client can also authenticate with its client ID and client secret to introspect one of its own tokens. Public clients must not receive a project API key or client secret. + +# Revoke a token {% #revoke %} + +A third-party client should revoke its token when the user disconnects the integration or the client no longer needs access. Revoking either the current access token or refresh token deletes the OAuth identity for that client and user, so both current tokens stop working. + +This public-client example sends JSON. A confidential client also includes its `client_secret`. + +{% multicode %} +```curl +curl -X POST 'https://.cloud.appwrite.io/v1/oauth2//revoke' \ + -H 'Content-Type: application/json' \ + -d '{ + "token": "", + "token_type_hint": "access_token", + "client_id": "" + }' +``` +```hurl +POST https://.cloud.appwrite.io/v1/oauth2//revoke +Content-Type: application/json +{ + "token": "", + "token_type_hint": "access_token", + "client_id": "" +} +``` +{% /multicode %} + +A successful revocation returns `200 OK` with an empty body. The endpoint returns the same response for an unknown token so callers cannot use it to discover valid tokens. + +# Sign out from the authorization server {% #logout %} + +Revocation disconnects one client without ending the user's browser session on your project. OpenID Connect logout ends that Appwrite session and revokes the tokens issued to the app identified by the ID token. This is useful for official apps that share your authorization server and need signing out of one app to require a fresh project sign-in elsewhere. + +Other clients' existing OAuth tokens remain valid. Revoke those clients separately if your security policy requires it. + +Before using logout, add the destination to the app's **Post-logout redirect URIs** in **Auth > OAuth2 server > Apps**. The URI in the logout request must match a registered value exactly. + +Send the browser to the logout endpoint with the ID token previously issued to the app: + +{% multicode %} +```curl +curl -G 'https://.cloud.appwrite.io/v1/oauth2//logout' \ + --data-urlencode 'id_token_hint=' \ + --data-urlencode 'post_logout_redirect_uri=https://client.example.com/signed-out' \ + --data-urlencode 'state=' +``` +```hurl +GET https://.cloud.appwrite.io/v1/oauth2//logout +[Query] +id_token_hint: +post_logout_redirect_uri: https://client.example.com/signed-out +state: +``` +{% /multicode %} + +Appwrite verifies the ID token's signature, issuer, client, and user. It accepts an expired ID token as a logout hint, but still requires the token to be validly signed. After deleting the current project session and the app's OAuth identity, Appwrite redirects to the registered URI and returns `state` unchanged. If no post-logout redirect URI is supplied, the endpoint returns `204 No Content`. diff --git a/static/images/docs/oauth-server/dark/diagram-authorization.avif b/static/images/docs/oauth-server/dark/diagram-authorization.avif new file mode 100644 index 0000000000..cd9c4fce61 Binary files /dev/null and b/static/images/docs/oauth-server/dark/diagram-authorization.avif differ diff --git a/static/images/docs/oauth-server/dark/diagram-clients.avif b/static/images/docs/oauth-server/dark/diagram-clients.avif new file mode 100644 index 0000000000..565a9a2ae3 Binary files /dev/null and b/static/images/docs/oauth-server/dark/diagram-clients.avif differ diff --git a/static/images/docs/oauth-server/dark/diagram-device-flow.avif b/static/images/docs/oauth-server/dark/diagram-device-flow.avif new file mode 100644 index 0000000000..4b562e0b5d Binary files /dev/null and b/static/images/docs/oauth-server/dark/diagram-device-flow.avif differ diff --git a/static/images/docs/oauth-server/dark/diagram-overview.avif b/static/images/docs/oauth-server/dark/diagram-overview.avif new file mode 100644 index 0000000000..46af40e6d5 Binary files /dev/null and b/static/images/docs/oauth-server/dark/diagram-overview.avif differ diff --git a/static/images/docs/oauth-server/dark/diagram-scopes.avif b/static/images/docs/oauth-server/dark/diagram-scopes.avif new file mode 100644 index 0000000000..2c4d72e3cf Binary files /dev/null and b/static/images/docs/oauth-server/dark/diagram-scopes.avif differ diff --git a/static/images/docs/oauth-server/dark/diagram-tokens.avif b/static/images/docs/oauth-server/dark/diagram-tokens.avif new file mode 100644 index 0000000000..f496fb4a2f Binary files /dev/null and b/static/images/docs/oauth-server/dark/diagram-tokens.avif differ diff --git a/static/images/docs/oauth-server/dark/oauth2-server-apps-empty.avif b/static/images/docs/oauth-server/dark/oauth2-server-apps-empty.avif new file mode 100644 index 0000000000..ccfb9ddc61 Binary files /dev/null and b/static/images/docs/oauth-server/dark/oauth2-server-apps-empty.avif differ diff --git a/static/images/docs/oauth-server/dark/oauth2-server-apps-list.avif b/static/images/docs/oauth-server/dark/oauth2-server-apps-list.avif new file mode 100644 index 0000000000..44aaed9878 Binary files /dev/null and b/static/images/docs/oauth-server/dark/oauth2-server-apps-list.avif differ diff --git a/static/images/docs/oauth-server/dark/oauth2-server-create-app.avif b/static/images/docs/oauth-server/dark/oauth2-server-create-app.avif new file mode 100644 index 0000000000..94bd4a953e Binary files /dev/null and b/static/images/docs/oauth-server/dark/oauth2-server-create-app.avif differ diff --git a/static/images/docs/oauth-server/dark/oauth2-server-discovery.avif b/static/images/docs/oauth-server/dark/oauth2-server-discovery.avif new file mode 100644 index 0000000000..1ac2b60176 Binary files /dev/null and b/static/images/docs/oauth-server/dark/oauth2-server-discovery.avif differ diff --git a/static/images/docs/oauth-server/dark/oauth2-server-secret-created.avif b/static/images/docs/oauth-server/dark/oauth2-server-secret-created.avif new file mode 100644 index 0000000000..2ba610911f Binary files /dev/null and b/static/images/docs/oauth-server/dark/oauth2-server-secret-created.avif differ diff --git a/static/images/docs/oauth-server/dark/oauth2-server-settings-disabled.avif b/static/images/docs/oauth-server/dark/oauth2-server-settings-disabled.avif new file mode 100644 index 0000000000..fe064cde64 Binary files /dev/null and b/static/images/docs/oauth-server/dark/oauth2-server-settings-disabled.avif differ diff --git a/static/images/docs/oauth-server/dark/oauth2-server-settings.avif b/static/images/docs/oauth-server/dark/oauth2-server-settings.avif new file mode 100644 index 0000000000..de48efaec8 Binary files /dev/null and b/static/images/docs/oauth-server/dark/oauth2-server-settings.avif differ diff --git a/static/images/docs/oauth-server/dark/oauth2-server-token-lifetimes.avif b/static/images/docs/oauth-server/dark/oauth2-server-token-lifetimes.avif new file mode 100644 index 0000000000..dc60fa6ba6 Binary files /dev/null and b/static/images/docs/oauth-server/dark/oauth2-server-token-lifetimes.avif differ diff --git a/static/images/docs/oauth-server/dark/scopes-defined.avif b/static/images/docs/oauth-server/dark/scopes-defined.avif new file mode 100644 index 0000000000..b9a5517403 Binary files /dev/null and b/static/images/docs/oauth-server/dark/scopes-defined.avif differ diff --git a/static/images/docs/oauth-server/diagram-authorization.avif b/static/images/docs/oauth-server/diagram-authorization.avif new file mode 100644 index 0000000000..6991f58ba5 Binary files /dev/null and b/static/images/docs/oauth-server/diagram-authorization.avif differ diff --git a/static/images/docs/oauth-server/diagram-clients.avif b/static/images/docs/oauth-server/diagram-clients.avif new file mode 100644 index 0000000000..430c22c882 Binary files /dev/null and b/static/images/docs/oauth-server/diagram-clients.avif differ diff --git a/static/images/docs/oauth-server/diagram-device-flow.avif b/static/images/docs/oauth-server/diagram-device-flow.avif new file mode 100644 index 0000000000..7f3517e545 Binary files /dev/null and b/static/images/docs/oauth-server/diagram-device-flow.avif differ diff --git a/static/images/docs/oauth-server/diagram-overview.avif b/static/images/docs/oauth-server/diagram-overview.avif new file mode 100644 index 0000000000..531e123229 Binary files /dev/null and b/static/images/docs/oauth-server/diagram-overview.avif differ diff --git a/static/images/docs/oauth-server/diagram-scopes.avif b/static/images/docs/oauth-server/diagram-scopes.avif new file mode 100644 index 0000000000..aabd371fc8 Binary files /dev/null and b/static/images/docs/oauth-server/diagram-scopes.avif differ diff --git a/static/images/docs/oauth-server/diagram-tokens.avif b/static/images/docs/oauth-server/diagram-tokens.avif new file mode 100644 index 0000000000..83a12298ec Binary files /dev/null and b/static/images/docs/oauth-server/diagram-tokens.avif differ diff --git a/static/images/docs/oauth-server/guide/taskflow-consent.avif b/static/images/docs/oauth-server/guide/taskflow-consent.avif new file mode 100644 index 0000000000..7bdfe58faa Binary files /dev/null and b/static/images/docs/oauth-server/guide/taskflow-consent.avif differ diff --git a/static/images/docs/oauth-server/guide/taskflow-login.avif b/static/images/docs/oauth-server/guide/taskflow-login.avif new file mode 100644 index 0000000000..a65883c42d Binary files /dev/null and b/static/images/docs/oauth-server/guide/taskflow-login.avif differ diff --git a/static/images/docs/oauth-server/guide/vantage-dashboard.avif b/static/images/docs/oauth-server/guide/vantage-dashboard.avif new file mode 100644 index 0000000000..914c30a57c Binary files /dev/null and b/static/images/docs/oauth-server/guide/vantage-dashboard.avif differ diff --git a/static/images/docs/oauth-server/guide/vantage-landing.avif b/static/images/docs/oauth-server/guide/vantage-landing.avif new file mode 100644 index 0000000000..ced2795a5c Binary files /dev/null and b/static/images/docs/oauth-server/guide/vantage-landing.avif differ diff --git a/static/images/docs/oauth-server/oauth2-server-apps-empty.avif b/static/images/docs/oauth-server/oauth2-server-apps-empty.avif new file mode 100644 index 0000000000..9b21288189 Binary files /dev/null and b/static/images/docs/oauth-server/oauth2-server-apps-empty.avif differ diff --git a/static/images/docs/oauth-server/oauth2-server-apps-list.avif b/static/images/docs/oauth-server/oauth2-server-apps-list.avif new file mode 100644 index 0000000000..9980ef09d7 Binary files /dev/null and b/static/images/docs/oauth-server/oauth2-server-apps-list.avif differ diff --git a/static/images/docs/oauth-server/oauth2-server-create-app.avif b/static/images/docs/oauth-server/oauth2-server-create-app.avif new file mode 100644 index 0000000000..c437b93ebb Binary files /dev/null and b/static/images/docs/oauth-server/oauth2-server-create-app.avif differ diff --git a/static/images/docs/oauth-server/oauth2-server-discovery.avif b/static/images/docs/oauth-server/oauth2-server-discovery.avif new file mode 100644 index 0000000000..123e7ff34f Binary files /dev/null and b/static/images/docs/oauth-server/oauth2-server-discovery.avif differ diff --git a/static/images/docs/oauth-server/oauth2-server-secret-created.avif b/static/images/docs/oauth-server/oauth2-server-secret-created.avif new file mode 100644 index 0000000000..186bb07bc3 Binary files /dev/null and b/static/images/docs/oauth-server/oauth2-server-secret-created.avif differ diff --git a/static/images/docs/oauth-server/oauth2-server-settings-disabled.avif b/static/images/docs/oauth-server/oauth2-server-settings-disabled.avif new file mode 100644 index 0000000000..63783ca245 Binary files /dev/null and b/static/images/docs/oauth-server/oauth2-server-settings-disabled.avif differ diff --git a/static/images/docs/oauth-server/oauth2-server-settings.avif b/static/images/docs/oauth-server/oauth2-server-settings.avif new file mode 100644 index 0000000000..04a12b329e Binary files /dev/null and b/static/images/docs/oauth-server/oauth2-server-settings.avif differ diff --git a/static/images/docs/oauth-server/oauth2-server-token-lifetimes.avif b/static/images/docs/oauth-server/oauth2-server-token-lifetimes.avif new file mode 100644 index 0000000000..8266beb4b4 Binary files /dev/null and b/static/images/docs/oauth-server/oauth2-server-token-lifetimes.avif differ diff --git a/static/images/docs/oauth-server/scopes-defined.avif b/static/images/docs/oauth-server/scopes-defined.avif new file mode 100644 index 0000000000..22023b347a Binary files /dev/null and b/static/images/docs/oauth-server/scopes-defined.avif differ diff --git a/static/images/docs/oauth-server/scopes-guide/taskflow-consent-choice.avif b/static/images/docs/oauth-server/scopes-guide/taskflow-consent-choice.avif new file mode 100644 index 0000000000..8888eba0a9 Binary files /dev/null and b/static/images/docs/oauth-server/scopes-guide/taskflow-consent-choice.avif differ diff --git a/static/images/docs/oauth-server/scopes-guide/taskflow-consent-scopes.avif b/static/images/docs/oauth-server/scopes-guide/taskflow-consent-scopes.avif new file mode 100644 index 0000000000..4a119e1569 Binary files /dev/null and b/static/images/docs/oauth-server/scopes-guide/taskflow-consent-scopes.avif differ diff --git a/static/images/docs/oauth-server/scopes-guide/vantage-tasks-dashboard.avif b/static/images/docs/oauth-server/scopes-guide/vantage-tasks-dashboard.avif new file mode 100644 index 0000000000..ceac007fb1 Binary files /dev/null and b/static/images/docs/oauth-server/scopes-guide/vantage-tasks-dashboard.avif differ diff --git a/static/images/docs/oauth-server/scopes-guide/vantage-write-denied.avif b/static/images/docs/oauth-server/scopes-guide/vantage-write-denied.avif new file mode 100644 index 0000000000..b142fe1c76 Binary files /dev/null and b/static/images/docs/oauth-server/scopes-guide/vantage-write-denied.avif differ diff --git a/static/images/docs/oauth-server/scopes-guide/vantage-write-granted.avif b/static/images/docs/oauth-server/scopes-guide/vantage-write-granted.avif new file mode 100644 index 0000000000..6615d730ea Binary files /dev/null and b/static/images/docs/oauth-server/scopes-guide/vantage-write-granted.avif differ