diff --git a/.optimize-cache.json b/.optimize-cache.json index bba480b445..ed5cd56343 100644 --- a/.optimize-cache.json +++ b/.optimize-cache.json @@ -1587,6 +1587,26 @@ "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/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": "8237a7890f6246661086cf80b7eb6cdbf0b27b4bc1a5cbcf485209ef9a27dfcf", + "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/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": "182c207475ebd8f386d3d1ec522058f23f59cb47d85f4b681889d0dd08eb8cd8", + "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/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/routes/docs/Sidebar.svelte b/src/routes/docs/Sidebar.svelte index 1dd3ea69ec..b1c7a1edfa 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: 'OAuth 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..409e8402dc --- /dev/null +++ b/src/routes/docs/partners/oauth-server/+layout.svelte @@ -0,0 +1,64 @@ + + + + + + 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..b3ec80274e --- /dev/null +++ b/src/routes/docs/partners/oauth-server/+page.markdoc @@ -0,0 +1,68 @@ +--- +layout: article +title: OAuth server +description: Turn your Appwrite project into an OAuth 2.1 and OpenID Connect provider so third-party apps can sign in with your product. +back: /docs +--- + +Appwrite can act as an **OAuth 2.1 and OpenID Connect provider**. When you enable the OAuth server on a project, third-party apps register as clients, send your users to a consent screen you host, and receive tokens your project issues. The users in your project become an identity provider that any standards-compliant OAuth or OIDC library can integrate with, the same way apps integrate with "Sign in with Google" or "Sign in with GitHub". + +{% 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 %} + +This is the inverse of the [OAuth providers](/docs/partners/project/oauth) feature. OAuth providers let your users sign in to your project *with* an external service. The OAuth server lets external services sign their users in *with your project*. + +# How it works {% #how-it-works %} + +The OAuth server has three moving parts. + +1. **The authorization server.** Enabling the server on your project exposes a full set of OIDC endpoints: authorization, token, userinfo, discovery, JWKS, revocation, and introspection. A keypair is generated for your project on first enable and used to sign tokens. +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). Clients declare the redirect URIs they are allowed to return to. +3. **The consent screen.** You host a consent screen at an authorization URL you configure. When a user authorizes a client, Appwrite redirects them 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 with PKCE, 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. + +# Standards support {% #standards %} + +The server implements the current OAuth 2.1 and OpenID Connect security practices: + +- **Authorization code grant with PKCE.** PKCE uses `S256` and is required for public clients. The password grant and the implicit access-token grant are not supported. +- **Refresh tokens with rotation.** Every refresh issues a new refresh token and invalidates the previous one. Reusing an old refresh token revokes the whole token family. +- **Exact redirect URI matching.** A returned redirect URI must exactly match a registered one, with a narrow loopback-port exception for public native clients (RFC 8252). +- **OpenID Connect.** The server issues `id_token` values, serves a discovery document at `/.well-known/openid-configuration`, and publishes signing keys at `/.well-known/jwks.json`. +- **Device authorization grant** (RFC 8628) for input-constrained devices like TVs and CLIs. + +# 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 %} + +# Guide {% #guide %} + +{% 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 %} 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..be7872d8d9 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/authorization/+page.markdoc @@ -0,0 +1,522 @@ +--- +layout: article +title: Authorization +description: The OAuth 2.1 authorization code flow with PKCE, and how to host a consent screen for your Appwrite OAuth server. +back: /docs/partners/oauth-server +--- + +Authorization is the step where a user allows a client to act on their behalf. Appwrite's OAuth server uses the **authorization code flow with PKCE**, the flow the OAuth 2.1 draft recommends for every client type. This page covers the flow end to end and how to host the consent screen it depends on. + +# The authorization code flow {% #flow %} + +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 for a user session on your project. With a session, it creates a grant and redirects to your **authorization URL** with a `grant_id`. Without one, it redirects to your authorization URL with the full request so you can sign the user in first. +3. Your **consent screen** loads the grant, shows the user what is being requested, and approves or rejects it. +4. On approval, Appwrite redirects back to the client's redirect URI with a short-lived authorization `code`. +5. 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, so an intercepted code cannot be redeemed by anyone else. The client generates a random `code_verifier`, hashes it with SHA-256 into a `code_challenge`, and sends the challenge on the authorize request. When it later redeems the code, it presents the original verifier, and the server checks that they match. + +The server supports the `S256` challenge method only. PKCE is **always required for public clients**. For confidential clients it is optional and controlled per project by the **Require PKCE** setting, since those clients already authenticate with a secret. + +# Start authorization {% #authorize %} + +Send the user to the authorization endpoint. A public client includes its `code_challenge`; a confidential client includes it when PKCE is enabled. + +{% 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 %} + +Without a session on the project, Appwrite redirects to your authorization URL with the original request parameters attached. Sign the user in there (with your normal [Appwrite authentication](/docs/products/auth)), then send them back through the authorize endpoint so a grant can be created. + +# Host the consent screen {% #consent %} + +Your authorization URL is a page you host. When Appwrite redirects a signed-in user to it with a `grant_id`, the page reads the grant, presents it, and records the user's decision. The consent endpoints authenticate with the user's session on your project. + +**Load the grant** to see which client, scopes, and resources are being requested, then render your UI. + +{% info title="Required scope" %} +Reading a grant requires the `oauth2.read` scope. Approving or rejecting one requires `oauth2.write`. +{% /info %} + +{% 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 %} + +**Approve** the grant to continue. Appwrite responds with the redirect URL back to the client, carrying the authorization code. 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.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 %} + +**Reject** the grant if the user declines. Appwrite responds with the redirect URL carrying an error instead of a code. + +{% 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 %} + +# 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 is what 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, port included. 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..d5e364907f --- /dev/null +++ b/src/routes/docs/partners/oauth-server/clients/+page.markdoc @@ -0,0 +1,1578 @@ +--- +layout: article +title: Clients +description: Register confidential and public OAuth clients against your Appwrite project's OAuth server and manage their secrets. +back: /docs/partners/oauth-server +--- + +A **client** is a third-party app that authenticates users through your project's OAuth server. Each client registers the redirect URIs it is allowed to return to, and, depending on its type, receives a client secret. Clients are managed from the **Auth > OAuth2 server > Apps** tab in the Console, or with a [Server SDK](/docs/sdks#server). + +{% 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 %} + +# Confidential and public clients {% #client-types %} + +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 | Allowed | Not allowed | +| 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 from the **Apps** tab, or with the `create` method of the `Apps` service. Registering a confidential client returns its secret once. Store it immediately, because the full value cannot be retrieved again. + +{% 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="Required scope" %} +The API key used for this call needs the `apps.write` scope. +{% /info %} + +{% 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 result = await apps.create({ + appId: '', + name: '', + redirectUris: [], + description: '', // optional + clientUri: 'https://example.com', // optional + logoUri: 'https://example.com', // optional + privacyPolicyUrl: 'https://example.com', // optional + termsUrl: 'https://example.com', // optional + contacts: [], // optional + tagline: '', // optional + tags: [], // optional + images: [], // optional + supportUrl: 'https://example.com', // optional + dataDeletionUrl: 'https://example.com', // optional + postLogoutRedirectUris: [], // optional + enabled: false, // optional + type: 'public', // optional + deviceFlow: false, // optional + teamId: '' // optional +}); +``` +```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 result = await apps.create({ + appId: '', + name: '', + redirectUris: [], + description: '', // optional + clientUri: 'https://example.com', // optional + logoUri: 'https://example.com', // optional + privacyPolicyUrl: 'https://example.com', // optional + termsUrl: 'https://example.com', // optional + contacts: [], // optional + tagline: '', // optional + tags: [], // optional + images: [], // optional + supportUrl: 'https://example.com', // optional + dataDeletionUrl: 'https://example.com', // optional + postLogoutRedirectUris: [], // optional + enabled: false, // optional + type: 'public', // optional + deviceFlow: false, // optional + teamId: '' // optional +}); +``` +```server-php +```php +setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + ->setProject('') // Your project ID + ->setKey(''); // Your secret API key + +$apps = new Apps($client); + +$result = $apps->create( + appId: '', + name: '', + redirectUris: [], + description: '', // optional + clientUri: 'https://example.com', // optional + logoUri: 'https://example.com', // optional + privacyPolicyUrl: 'https://example.com', // optional + termsUrl: 'https://example.com', // optional + contacts: [], // optional + tagline: '', // optional + tags: [], // optional + images: [], // optional + supportUrl: 'https://example.com', // optional + dataDeletionUrl: 'https://example.com', // optional + postLogoutRedirectUris: [], // optional + enabled: false, // optional + type: 'public', // optional + deviceFlow: false, // optional + teamId: '' // optional +);``` +``` +```server-python +from appwrite.client import Client +from appwrite.services.apps import Apps +from appwrite.models import App + +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) + +result: App = apps.create( + app_id = '', + name = '', + redirect_uris = [], + description = '', # optional + client_uri = 'https://example.com', # optional + logo_uri = 'https://example.com', # optional + privacy_policy_url = 'https://example.com', # optional + terms_url = 'https://example.com', # optional + contacts = [], # optional + tagline = '', # optional + tags = [], # optional + images = [], # optional + support_url = 'https://example.com', # optional + data_deletion_url = 'https://example.com', # optional + post_logout_redirect_uris = [], # optional + enabled = False, # optional + type = 'public', # optional + device_flow = False, # optional + team_id = '' # 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 + +apps = Apps.new(client) + +result = apps.create( + app_id: '', + name: '', + redirect_uris: [], + description: '', # optional + client_uri: 'https://example.com', # optional + logo_uri: 'https://example.com', # optional + privacy_policy_url: 'https://example.com', # optional + terms_url: 'https://example.com', # optional + contacts: [], # optional + tagline: '', # optional + tags: [], # optional + images: [], # optional + support_url: 'https://example.com', # optional + data_deletion_url: 'https://example.com', # optional + post_logout_redirect_uris: [], # optional + enabled: false, # optional + type: 'public', # optional + device_flow: false, # optional + team_id: '' # 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 + +Apps apps = Apps(client); + +App result = await apps.create( + appId: '', + name: '', + redirectUris: [], + description: '', // (optional) + clientUri: 'https://example.com', // (optional) + logoUri: 'https://example.com', // (optional) + privacyPolicyUrl: 'https://example.com', // (optional) + termsUrl: 'https://example.com', // (optional) + contacts: [], // (optional) + tagline: '', // (optional) + tags: [], // (optional) + images: [], // (optional) + supportUrl: 'https://example.com', // (optional) + dataDeletionUrl: 'https://example.com', // (optional) + postLogoutRedirectUris: [], // (optional) + enabled: false, // (optional) + type: 'public', // (optional) + deviceFlow: false, // (optional) + teamId: '', // (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 + +Apps apps = new Apps(client); + +App result = await apps.Create( + appId: "", + name: "", + redirectUris: new List(), + description: "", // optional + clientUri: "https://example.com", // optional + logoUri: "https://example.com", // optional + privacyPolicyUrl: "https://example.com", // optional + termsUrl: "https://example.com", // optional + contacts: new List(), // optional + tagline: "", // optional + tags: new List(), // optional + images: new List(), // optional + supportUrl: "https://example.com", // optional + dataDeletionUrl: "https://example.com", // optional + postLogoutRedirectUris: new List(), // optional + enabled: false, // optional + type: "public", // optional + deviceFlow: false, // optional + teamId: "" // optional +);``` +``` +```server-kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +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 response = apps.create( + appId = "", + name = "", + redirectUris = listOf(), + description = "", // optional + clientUri = "https://example.com", // optional + logoUri = "https://example.com", // optional + privacyPolicyUrl = "https://example.com", // optional + termsUrl = "https://example.com", // optional + contacts = listOf(), // optional + tagline = "", // optional + tags = listOf(), // optional + images = listOf(), // optional + supportUrl = "https://example.com", // optional + dataDeletionUrl = "https://example.com", // optional + postLogoutRedirectUris = listOf(), // optional + enabled = false, // optional + type = "public", // optional + deviceFlow = false, // optional + teamId = "" // optional +) +``` +```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.create( + "", // appId + "", // name + List.of(), // redirectUris + "", // description (optional) + "https://example.com", // clientUri (optional) + "https://example.com", // logoUri (optional) + "https://example.com", // privacyPolicyUrl (optional) + "https://example.com", // termsUrl (optional) + List.of(), // contacts (optional) + "", // tagline (optional) + List.of(), // tags (optional) + List.of(), // images (optional) + "https://example.com", // supportUrl (optional) + "https://example.com", // dataDeletionUrl (optional) + List.of(), // postLogoutRedirectUris (optional) + false, // enabled (optional) + "public", // type (optional) + false, // deviceFlow (optional) + "", // teamId (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 apps = Apps(client) + +let app = try await apps.create( + appId: "", + name: "", + redirectUris: [], + description: "", // optional + clientUri: "https://example.com", // optional + logoUri: "https://example.com", // optional + privacyPolicyUrl: "https://example.com", // optional + termsUrl: "https://example.com", // optional + contacts: [], // optional + tagline: "", // optional + tags: [], // optional + images: [], // optional + supportUrl: "https://example.com", // optional + dataDeletionUrl: "https://example.com", // optional + postLogoutRedirectUris: [], // optional + enabled: false, // optional + type: "public", // optional + deviceFlow: false, // optional + teamId: "" // optional +) + +``` +```server-go +package main + +import ( + "fmt" + "github.com/repoowner/reponame/client" + "github.com/repoowner/reponame/apps" +) + +client := client.New( + client.WithEndpoint("https://.cloud.appwrite.io/v1") + client.WithProject("") + appwrite.WithKey("") +) + +service := apps.New(client) + +response, error := service.Create( + "", + "", + []string{}, + apps.WithCreateDescription(""), + apps.WithCreateClientUri("https://example.com"), + apps.WithCreateLogoUri("https://example.com"), + apps.WithCreatePrivacyPolicyUrl("https://example.com"), + apps.WithCreateTermsUrl("https://example.com"), + apps.WithCreateContacts([]string{}), + apps.WithCreateTagline(""), + apps.WithCreateTags([]string{}), + apps.WithCreateImages([]string{}), + apps.WithCreateSupportUrl("https://example.com"), + apps.WithCreateDataDeletionUrl("https://example.com"), + apps.WithCreatePostLogoutRedirectUris([]string{}), + apps.WithCreateEnabled(false), + apps.WithCreateType("public"), + apps.WithCreateDeviceFlow(false), + apps.WithCreateTeamId(""), +) +``` +```server-rust +use appwrite::Client; +use appwrite::services::Apps; + +#[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 apps = Apps::new(&client); + + let result = apps.create( + "", + "", + vec![], + Some(""), // optional + Some("https://example.com"), // optional + Some("https://example.com"), // optional + Some("https://example.com"), // optional + Some("https://example.com"), // optional + Some(vec![]), // optional + Some(""), // optional + Some(vec![]), // optional + Some(vec![]), // optional + Some("https://example.com"), // optional + Some("https://example.com"), // optional + Some(vec![]), // optional + Some(false), // optional + Some("public"), // optional + Some(false), // optional + Some("") // optional + ).await?; + + let _ = result; + + Ok(()) +} +``` +{% /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`) appear on the consent screen your users see. + +# Manage client secrets {% #secrets %} + +A confidential client authenticates with a secret. Generate a new secret with the `createSecret` method. 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 %} +```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 result = await apps.createSecret({ + appId: '' +}); +``` +```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 result = await apps.createSecret({ + appId: '' +}); +``` +```server-php +```php +setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + ->setProject('') // Your project ID + ->setKey(''); // Your secret API key + +$apps = new Apps($client); + +$result = $apps->createSecret( + appId: '' +);``` +``` +```server-python +from appwrite.client import Client +from appwrite.services.apps import Apps +from appwrite.models import AppSecretPlaintext + +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) + +result: AppSecretPlaintext = apps.create_secret( + app_id = '' +) + +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 + +apps = Apps.new(client) + +result = apps.create_secret( + app_id: '' +) +``` +```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); + +AppSecretPlaintext result = await apps.createSecret( + appId: '', +); +``` +```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 + +Apps apps = new Apps(client); + +AppSecretPlaintext result = await apps.CreateSecret( + appId: "" +);``` +``` +```server-kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +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 response = apps.createSecret( + appId = "" +) +``` +```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.createSecret( + "", // appId + 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 appSecretPlaintext = try await apps.createSecret( + appId: "" +) + +``` +```server-go +package main + +import ( + "fmt" + "github.com/repoowner/reponame/client" + "github.com/repoowner/reponame/apps" +) + +client := client.New( + client.WithEndpoint("https://.cloud.appwrite.io/v1") + client.WithProject("") + appwrite.WithKey("") +) + +service := apps.New(client) + +response, error := service.CreateSecret( + "", +) +``` +```server-rust +use appwrite::Client; +use appwrite::services::Apps; + +#[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 apps = Apps::new(&client); + + let result = apps.create_secret( + "" + ).await?; + + let _ = result; + + Ok(()) +} +``` +{% /multicode %} + +A client can hold more than one secret at a time, which lets you rotate without downtime: create the new secret, deploy it, then delete the old one. Deleting a secret immediately breaks any client still using it, including token refreshes in flight. + +# List clients {% #list %} + +{% info title="Required scope" %} +The API key used for this call needs the `apps.read` scope. +{% /info %} + +{% 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 result = await apps.list({ + queries: [], // optional + total: false // optional +}); +``` +```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 result = await apps.list({ + queries: [], // optional + total: false // optional +}); +``` +```server-php +```php +setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + ->setProject('') // Your project ID + ->setKey(''); // Your secret API key + +$apps = new Apps($client); + +$result = $apps->list( + queries: [], // optional + total: false // optional +);``` +``` +```server-python +from appwrite.client import Client +from appwrite.services.apps import Apps +from appwrite.models import AppsList + +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) + +result: AppsList = apps.list( + queries = [], # optional + total = False # 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 + +apps = Apps.new(client) + +result = apps.list( + queries: [], # optional + total: false # 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 + +Apps apps = Apps(client); + +AppsList result = await apps.list( + queries: [], // (optional) + total: false, // (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 + +Apps apps = new Apps(client); + +AppsList result = await apps.List( + queries: new List(), // optional + total: false // optional +);``` +``` +```server-kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +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 response = apps.list( + queries = listOf(), // optional + total = false // optional +) +``` +```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.list( + List.of(), // queries (optional) + false, // total (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 apps = Apps(client) + +let appsList = try await apps.list( + queries: [], // optional + total: false // optional +) + +``` +```server-go +package main + +import ( + "fmt" + "github.com/repoowner/reponame/client" + "github.com/repoowner/reponame/apps" +) + +client := client.New( + client.WithEndpoint("https://.cloud.appwrite.io/v1") + client.WithProject("") + appwrite.WithKey("") +) + +service := apps.New(client) + +response, error := service.List( + apps.WithListQueries([]string{}), + apps.WithListTotal(false), +) +``` +```server-rust +use appwrite::Client; +use appwrite::services::Apps; + +#[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 apps = Apps::new(&client); + + let result = apps.list( + Some(vec![]), // optional + Some(false) // optional + ).await?; + + let _ = result; + + Ok(()) +} +``` +{% /multicode %} + +# Update a client {% #update %} + +Change a client's redirect URIs, branding, or type with the `update` method. + +{% 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 result = await apps.update({ + appId: '', + name: '', + description: '', // optional + clientUri: 'https://example.com', // optional + logoUri: 'https://example.com', // optional + privacyPolicyUrl: 'https://example.com', // optional + termsUrl: 'https://example.com', // optional + contacts: [], // optional + tagline: '', // optional + tags: [], // optional + images: [], // optional + supportUrl: 'https://example.com', // optional + dataDeletionUrl: 'https://example.com', // optional + enabled: false, // optional + redirectUris: [], // optional + postLogoutRedirectUris: [], // optional + type: 'public', // optional + deviceFlow: false // optional +}); +``` +```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 result = await apps.update({ + appId: '', + name: '', + description: '', // optional + clientUri: 'https://example.com', // optional + logoUri: 'https://example.com', // optional + privacyPolicyUrl: 'https://example.com', // optional + termsUrl: 'https://example.com', // optional + contacts: [], // optional + tagline: '', // optional + tags: [], // optional + images: [], // optional + supportUrl: 'https://example.com', // optional + dataDeletionUrl: 'https://example.com', // optional + enabled: false, // optional + redirectUris: [], // optional + postLogoutRedirectUris: [], // optional + type: 'public', // optional + deviceFlow: false // optional +}); +``` +```server-php +```php +setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + ->setProject('') // Your project ID + ->setKey(''); // Your secret API key + +$apps = new Apps($client); + +$result = $apps->update( + appId: '', + name: '', + description: '', // optional + clientUri: 'https://example.com', // optional + logoUri: 'https://example.com', // optional + privacyPolicyUrl: 'https://example.com', // optional + termsUrl: 'https://example.com', // optional + contacts: [], // optional + tagline: '', // optional + tags: [], // optional + images: [], // optional + supportUrl: 'https://example.com', // optional + dataDeletionUrl: 'https://example.com', // optional + enabled: false, // optional + redirectUris: [], // optional + postLogoutRedirectUris: [], // optional + type: 'public', // optional + deviceFlow: false // optional +);``` +``` +```server-python +from appwrite.client import Client +from appwrite.services.apps import Apps +from appwrite.models import App + +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) + +result: App = apps.update( + app_id = '', + name = '', + description = '', # optional + client_uri = 'https://example.com', # optional + logo_uri = 'https://example.com', # optional + privacy_policy_url = 'https://example.com', # optional + terms_url = 'https://example.com', # optional + contacts = [], # optional + tagline = '', # optional + tags = [], # optional + images = [], # optional + support_url = 'https://example.com', # optional + data_deletion_url = 'https://example.com', # optional + enabled = False, # optional + redirect_uris = [], # optional + post_logout_redirect_uris = [], # optional + type = 'public', # optional + device_flow = False # 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 + +apps = Apps.new(client) + +result = apps.update( + app_id: '', + name: '', + description: '', # optional + client_uri: 'https://example.com', # optional + logo_uri: 'https://example.com', # optional + privacy_policy_url: 'https://example.com', # optional + terms_url: 'https://example.com', # optional + contacts: [], # optional + tagline: '', # optional + tags: [], # optional + images: [], # optional + support_url: 'https://example.com', # optional + data_deletion_url: 'https://example.com', # optional + enabled: false, # optional + redirect_uris: [], # optional + post_logout_redirect_uris: [], # optional + type: 'public', # optional + device_flow: false # 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 + +Apps apps = Apps(client); + +App result = await apps.update( + appId: '', + name: '', + description: '', // (optional) + clientUri: 'https://example.com', // (optional) + logoUri: 'https://example.com', // (optional) + privacyPolicyUrl: 'https://example.com', // (optional) + termsUrl: 'https://example.com', // (optional) + contacts: [], // (optional) + tagline: '', // (optional) + tags: [], // (optional) + images: [], // (optional) + supportUrl: 'https://example.com', // (optional) + dataDeletionUrl: 'https://example.com', // (optional) + enabled: false, // (optional) + redirectUris: [], // (optional) + postLogoutRedirectUris: [], // (optional) + type: 'public', // (optional) + deviceFlow: false, // (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 + +Apps apps = new Apps(client); + +App result = await apps.Update( + appId: "", + name: "", + description: "", // optional + clientUri: "https://example.com", // optional + logoUri: "https://example.com", // optional + privacyPolicyUrl: "https://example.com", // optional + termsUrl: "https://example.com", // optional + contacts: new List(), // optional + tagline: "", // optional + tags: new List(), // optional + images: new List(), // optional + supportUrl: "https://example.com", // optional + dataDeletionUrl: "https://example.com", // optional + enabled: false, // optional + redirectUris: new List(), // optional + postLogoutRedirectUris: new List(), // optional + type: "public", // optional + deviceFlow: false // optional +);``` +``` +```server-kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +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 response = apps.update( + appId = "", + name = "", + description = "", // optional + clientUri = "https://example.com", // optional + logoUri = "https://example.com", // optional + privacyPolicyUrl = "https://example.com", // optional + termsUrl = "https://example.com", // optional + contacts = listOf(), // optional + tagline = "", // optional + tags = listOf(), // optional + images = listOf(), // optional + supportUrl = "https://example.com", // optional + dataDeletionUrl = "https://example.com", // optional + enabled = false, // optional + redirectUris = listOf(), // optional + postLogoutRedirectUris = listOf(), // optional + type = "public", // optional + deviceFlow = false // optional +) +``` +```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.update( + "", // appId + "", // name + "", // description (optional) + "https://example.com", // clientUri (optional) + "https://example.com", // logoUri (optional) + "https://example.com", // privacyPolicyUrl (optional) + "https://example.com", // termsUrl (optional) + List.of(), // contacts (optional) + "", // tagline (optional) + List.of(), // tags (optional) + List.of(), // images (optional) + "https://example.com", // supportUrl (optional) + "https://example.com", // dataDeletionUrl (optional) + false, // enabled (optional) + List.of(), // redirectUris (optional) + List.of(), // postLogoutRedirectUris (optional) + "public", // type (optional) + false, // deviceFlow (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 apps = Apps(client) + +let app = try await apps.update( + appId: "", + name: "", + description: "", // optional + clientUri: "https://example.com", // optional + logoUri: "https://example.com", // optional + privacyPolicyUrl: "https://example.com", // optional + termsUrl: "https://example.com", // optional + contacts: [], // optional + tagline: "", // optional + tags: [], // optional + images: [], // optional + supportUrl: "https://example.com", // optional + dataDeletionUrl: "https://example.com", // optional + enabled: false, // optional + redirectUris: [], // optional + postLogoutRedirectUris: [], // optional + type: "public", // optional + deviceFlow: false // optional +) + +``` +```server-go +package main + +import ( + "fmt" + "github.com/repoowner/reponame/client" + "github.com/repoowner/reponame/apps" +) + +client := client.New( + client.WithEndpoint("https://.cloud.appwrite.io/v1") + client.WithProject("") + appwrite.WithKey("") +) + +service := apps.New(client) + +response, error := service.Update( + "", + "", + apps.WithUpdateDescription(""), + apps.WithUpdateClientUri("https://example.com"), + apps.WithUpdateLogoUri("https://example.com"), + apps.WithUpdatePrivacyPolicyUrl("https://example.com"), + apps.WithUpdateTermsUrl("https://example.com"), + apps.WithUpdateContacts([]string{}), + apps.WithUpdateTagline(""), + apps.WithUpdateTags([]string{}), + apps.WithUpdateImages([]string{}), + apps.WithUpdateSupportUrl("https://example.com"), + apps.WithUpdateDataDeletionUrl("https://example.com"), + apps.WithUpdateEnabled(false), + apps.WithUpdateRedirectUris([]string{}), + apps.WithUpdatePostLogoutRedirectUris([]string{}), + apps.WithUpdateType("public"), + apps.WithUpdateDeviceFlow(false), +) +``` +```server-rust +use appwrite::Client; +use appwrite::services::Apps; + +#[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 apps = Apps::new(&client); + + let result = apps.update( + "", + "", + Some(""), // optional + Some("https://example.com"), // optional + Some("https://example.com"), // optional + Some("https://example.com"), // optional + Some("https://example.com"), // optional + Some(vec![]), // optional + Some(""), // optional + Some(vec![]), // optional + Some(vec![]), // optional + Some("https://example.com"), // optional + Some("https://example.com"), // optional + Some(false), // optional + Some(vec![]), // optional + Some(vec![]), // optional + Some("public"), // optional + Some(false) // optional + ).await?; + + let _ = result; + + Ok(()) +} +``` +{% /multicode %} + +# Delete a client {% #delete %} + +Deleting a client immediately invalidates every token issued to it. + +{% 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 result = await apps.delete({ + appId: '' +}); +``` +```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 result = await apps.delete({ + appId: '' +}); +``` +```server-php +```php +setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + ->setProject('') // Your project ID + ->setKey(''); // Your secret API key + +$apps = new Apps($client); + +$result = $apps->delete( + appId: '' +);``` +``` +```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) + +result = apps.delete( + app_id = '' +) +``` +```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) + +result = apps.delete( + app_id: '' +) +``` +```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); + +await apps.delete( + appId: '', +); +``` +```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 + +Apps apps = new Apps(client); + +await apps.Delete( + appId: "" +);``` +``` +```server-kotlin +import io.appwrite.Client +import io.appwrite.coroutines.CoroutineCallback +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 response = apps.delete( + appId = "" +) +``` +```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.delete( + "", // appId + 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 result = try await apps.delete( + appId: "" +) + +``` +```server-go +package main + +import ( + "fmt" + "github.com/repoowner/reponame/client" + "github.com/repoowner/reponame/apps" +) + +client := client.New( + client.WithEndpoint("https://.cloud.appwrite.io/v1") + client.WithProject("") + appwrite.WithKey("") +) + +service := apps.New(client) + +response, error := service.Delete( + "", +) +``` +```server-rust +use appwrite::Client; +use appwrite::services::Apps; + +#[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 apps = Apps::new(&client); + + let result = apps.delete( + "" + ).await?; + + let _ = result; + + Ok(()) +} +``` +{% /multicode %} 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..53e233e687 --- /dev/null +++ b/src/routes/docs/partners/oauth-server/device-flow/+page.markdoc @@ -0,0 +1,171 @@ +--- +layout: article +title: Device flow +description: Authorize TVs, CLIs, and other input-constrained devices against your Appwrite OAuth server with the device authorization grant. +back: /docs/partners/oauth-server +--- + +Some devices cannot show a browser or take typed input: a TV app, a CLI on a remote server, a hardware device. The **device authorization grant** (RFC 8628) lets these clients get tokens by having the user approve on a second device, like their phone or laptop. + +# How it works {% #how-it-works %} + +1. The device asks the OAuth server to start a device authorization. The server returns a **device code**, a short **user code**, and a **verification URL**. +2. The device shows the user code and verification URL to the user (for example, "Go to example.com/activate and enter WDJB-MJHT"). +3. The user opens the verification URL on another device, signs in, and approves. +4. Meanwhile the device polls the token endpoint. Once the user approves, the poll returns tokens. + +Device flow is available to a client only when both the project has a **verification URL** configured on the OAuth2 server settings and the client has **device flow** enabled. Device clients are public clients, so they authenticate with no secret. + +# Enable device flow on a client {% #enable %} + +Turn on **Device flow** when you create or update the client. In the Console, it is a toggle in the client dialog; with a Server SDK, set `deviceFlow: true` on the [client](/docs/partners/oauth-server/clients#register). + +# Start a device authorization {% #start %} + +The device requests a device authorization with its client ID and the scopes it wants. + +{% 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.createDeviceAuthorization({ + clientId: '', // optional + scope: '', // 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); + +Oauth2DeviceAuthorization result = await oauth2.createDeviceAuthorization( + clientId: '', // optional + scope: '', // 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 oauth2DeviceAuthorization = try await oauth2.createDeviceAuthorization( + client_id: "", // optional + scope: "", // 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.createDeviceAuthorization( + client_id = "", // (optional) + scope = "", // (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.createDeviceAuthorization( + "", // client_id (optional) + "", // scope (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.createDeviceAuthorization({ + clientId: '', // optional + scope: '', // optional + authorizationDetails: '', // optional + resource: '' // optional +}); + +console.log(result); +``` +{% /multicode %} + +The response contains the codes and where to send the user: + +```json +{ + "device_code": "8575375669b9031cb5371b0ee39985c2...", + "user_code": "WDJB-MJHT", + "verification_uri": "https://example.com/activate", + "verification_uri_complete": "https://example.com/activate?user_code=WDJB-MJHT", + "expires_in": 600, + "interval": 1 +} +``` + +Show the `user_code` and `verification_uri` to the user. The `verification_uri_complete` embeds the code so a phone can open it directly, often as a QR code. + +# Poll for tokens {% #poll %} + +While the user approves on their other device, the client polls the token endpoint with the device code grant, waiting `interval` seconds between attempts. + +```http +POST /v1/oauth2//token HTTP/1.1 +Host: .cloud.appwrite.io +Content-Type: application/x-www-form-urlencoded + +grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=&client_id= +``` + +Until the user approves, the endpoint returns `authorization_pending`; keep polling. Once they approve, it returns an access token, refresh token, and ID token, exactly like the [authorization code flow](/docs/partners/oauth-server/tokens). If the user declines or the codes expire, the poll returns a terminal error and the device restarts the flow. 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..e91f61c9eb --- /dev/null +++ b/src/routes/docs/partners/oauth-server/quick-start/+page.markdoc @@ -0,0 +1,468 @@ +--- +layout: article +title: OAuth server quick start +description: Enable Appwrite's OAuth server, register a client, and run your first authorization code sign-in end to end. +back: /docs/partners/oauth-server +--- + +This guide turns a project into an OAuth 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. + +# 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. You can point it at a placeholder for now and change it later. The `openid`, `profile`, and `email` scopes are always included; add any custom scopes your clients will request. + +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. + +```http +GET /v1/oauth2//.well-known/openid-configuration HTTP/1.1 +Host: .cloud.appwrite.io +``` + +# Register a client {% #register %} + +Open the **Apps** sub-tab and create a client. Give it a name, add at least one 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 %} + +See [Clients](/docs/partners/oauth-server/clients) for the full set of options and the SDK equivalents. + +# Run the authorization code flow {% #flow %} + +With the server enabled and a client registered, you can run a sign-in. The flow has three steps: send the user to authorize, let them consent, then exchange the returned code for tokens. + +**1. Send the user to the authorization endpoint.** Include the client ID, a registered redirect URI, `response_type=code`, and the scopes you want. If the user has a session on your project, Appwrite redirects to your authorization URL with a `grant_id`. Otherwise it redirects there with the full request so your screen can sign the user in first. + +```http +GET /v1/oauth2//authorize?client_id=&redirect_uri=&response_type=code&scope=openid%20profile%20email&state= HTTP/1.1 +Host: .cloud.appwrite.io +``` + +**2. Approve the grant.** Your consent screen loads the grant, shows the user what the client is requesting, and approves it. Appwrite then redirects back to the client's redirect URI with an authorization `code`. Building this screen is covered in [Authorization](/docs/partners/oauth-server/authorization). + +**3. Exchange the code for tokens.** The client sends the code to the token endpoint. A confidential client authenticates with HTTP Basic auth using its client ID and secret. + +```http +POST /v1/oauth2//token HTTP/1.1 +Host: .cloud.appwrite.io +Authorization: Basic +Content-Type: application/x-www-form-urlencoded + +grant_type=authorization_code&code=&redirect_uri= +``` + +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..." +} +``` + +The client can now call the userinfo endpoint with the access token, or validate the ID token against the server's JWKS. See [Tokens](/docs/partners/oauth-server/tokens) for token structure, 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..fc9474765d --- /dev/null +++ b/src/routes/docs/partners/oauth-server/scopes/+page.markdoc @@ -0,0 +1,40 @@ +--- +layout: article +title: Scopes +description: The built-in OpenID Connect scopes and the custom scopes clients can request from your Appwrite OAuth 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, such as `read:projects` or `write:billing`. Add them 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 OAuth 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 %} + +A client lists the scopes it wants in the `scope` parameter, space-separated, when it starts [authorization](/docs/partners/oauth-server/authorization#authorize). The user is shown exactly these scopes on your consent screen. Whatever they approve is what the issued access token carries in its `scope` claim, so a client should request only what it needs. 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..56f071e9f6 --- /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 OAuth server, from the consent screen to the token exchange. +step: 1 +difficulty: intermediate +readtime: 25 +framework: TanStack Start +category: OAuth server +back: /docs/partners/oauth-server +--- + +Once your project's [OAuth 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 OAuth 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 OAuth 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 OAuth 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..97c5a91157 --- /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 OAuth server +description: Turn on the OAuth 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 OAuth 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 OAuth 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 OAuth 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..cbed8da94f --- /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 OAuth 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 OAuth 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..0cdffe9d85 --- /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 OAuth 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 OAuth 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 OAuth 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 OAuth 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 OAuth 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..874fbedfdf --- /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 OAuth 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..4ffa7df73e --- /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 OAuth server. + +# What each side did {% #recap %} + +- **TaskFlow** enabled the OAuth 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..91a8901fce --- /dev/null +++ b/src/routes/docs/partners/oauth-server/tokens/+page.markdoc @@ -0,0 +1,348 @@ +--- +layout: article +title: Tokens +description: Access, refresh, and ID tokens issued by Appwrite's OAuth server, their lifetimes, and how to refresh, introspect, and revoke them. +back: /docs/partners/oauth-server +--- + +When a client redeems an authorization code, the OAuth server issues three tokens: an access token, a refresh token, and an ID token. This page covers what each one is, how long it lives, and how to refresh, validate, and revoke them. + +# The three tokens {% #tokens %} + +- **Access token.** A JWT the client sends to call APIs on the user's behalf. It is signed with your project's key (`RS256`) and carries the granted scopes. Present it as a `Bearer` token to the userinfo endpoint or your own resource server. +- **Refresh token.** An opaque token the client exchanges for a new access token when the old one expires, without sending the user through authorization again. +- **ID token.** A JWT from the OpenID Connect layer that identifies the user (subject, and any profile claims the granted scopes allow). Clients validate it against the server's published keys. + +# 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 contains the access token, refresh token, ID token, granted scope, and the access token's lifetime in seconds. + +# 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 calls the token endpoint with `grantType: 'refresh_token'` and its current refresh token. The response includes a **new** access token and a **new** refresh token. + +Refresh tokens rotate: each refresh invalidates the token that was used. If an old refresh token is presented again, the server treats it as a compromise and revokes the entire token family, forcing the user to reauthorize. This means a client must always store the newest refresh token it received. + +To refresh, reuse the `createToken` call with the refresh grant: + +```http +POST /v1/oauth2//token HTTP/1.1 +Host: .cloud.appwrite.io +Authorization: Basic +Content-Type: application/x-www-form-urlencoded + +grant_type=refresh_token&refresh_token= +``` + +# Validate tokens {% #validate %} + +Access and ID tokens are `RS256` JWTs signed with your project's key. A client validates them offline by fetching the server's public keys once from the JWKS endpoint and checking the signature, issuer, and expiry. + +```http +GET /v1/oauth2//.well-known/jwks.json HTTP/1.1 +Host: .cloud.appwrite.io +``` + +To read the user's profile from an access token, call the userinfo endpoint: + +```http +GET /v1/oauth2//userinfo HTTP/1.1 +Host: .cloud.appwrite.io +Authorization: Bearer +``` + +# Introspect a token {% #introspect %} + +A resource server can ask the OAuth server whether a token is still valid with the introspection endpoint. Introspection requires client authentication, so only confidential clients (or an API key with the `oauth2.read` scope) can call it. A public client, having no secret, cannot introspect. + +```http +POST /v1/oauth2//introspect HTTP/1.1 +Host: .cloud.appwrite.io +Authorization: Basic +Content-Type: application/x-www-form-urlencoded + +token= +``` + +A valid token returns `"active": true` with its scope, subject, and expiry. A revoked or expired token returns `"active": false`. + +# Revoke a token {% #revoke %} + +A client revokes a token it no longer needs with the revocation endpoint. Confidential clients authenticate with their secret; public clients revoke without one. + +{% 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.revoke({ + token: '', + tokenTypeHint: 'access_token', // optional + clientId: '', // optional + clientSecret: '' // 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); + + result = await oauth2.revoke( + token: '', + tokenTypeHint: 'access_token', // optional + clientId: '', // optional + clientSecret: '', // 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 result = try await oauth2.revoke( + token: "", + token_type_hint: "access_token", // optional + client_id: "", // optional + client_secret: "" // 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.revoke( + token = "", + token_type_hint = "access_token", // (optional) + client_id = "", // (optional) + client_secret = "", // (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.revoke( + "", // token + "access_token", // token_type_hint (optional) + "", // client_id (optional) + "", // client_secret (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.revoke({ + token: '', + tokenTypeHint: 'access_token', // optional + clientId: '', // optional + clientSecret: '' // optional +}); + +console.log(result); +``` +{% /multicode %} 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..a38ed59cfc 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/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..76a620c669 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