ExtremeX CLIENT

ExtremeX ONE — Integration documentation

ExtremeX ONE is the identity platform of the ExtremeX ecosystem. Your application integrates it once and gets secure sign-in, verified user identity, per-app user data storage, and a consent model users trust. This page is the complete technical reference; step-by-step walkthroughs live in the guides.

Quickstart

  1. Sign in to this portal with your ExtremeX ONE account and claim your namespace — your company's reverse domain, e.g. com.yourco.
  2. Register an application (service name, display name, redirect URL). You receive a client_id and a client_secret — the secret is shown once.
  3. Configure Data access: which fields you request, required or optional, and why. This is what users see on the consent screen.
  4. Send users to the authorize endpoint, exchange the returned code server-side, and call userinfo.

Core concepts

  • Namespace — your reverse domain (com.yourco). All app IDs live under it. It can be renamed from the dashboard, but that renames every app ID under it (stored data and consents are migrated automatically), so treat it as stable.
  • App IDnamespace.service, e.g. com.yourco.iwish. Also the namespace for your app's user data. Avoid hardcoding it where a namespace rename would hurt.
  • Client ID / secret — OAuth credentials. The secret is stored hashed on our side and must live only on your server.
  • Data fields — the granular pieces of identity your app requests: name, username, avatar, email, attributes (app data), offline (refresh token). Each is tagged required or optional with your stated reason.
  • Consent — checked live on every API call. When a user disconnects your app, access dies immediately, even for unexpired tokens.

OAuth 2.0 flow

ExtremeX ONE implements the standard authorization-code grant:

1. Browser  →  https://one.extremextechnology.com/oauth/authorize
              ?client_id=exone_xxxxxxxx
              &redirect_uri=https://app.yourco.com/auth/callback
              &state=<random-anti-csrf-value>

2. User signs in (if needed) and reviews the consent screen:
   your required fields are locked on, optional ones can be unticked.
   The user presses Proceed.

3. Browser  ←  302 https://app.yourco.com/auth/callback?code=…&state=…

4. Your server  →  POST https://api.extremextechnology.com/one/oauth/token   (exchange, server-side only)

5. Your server  →  GET https://api.extremextechnology.com/one/oauth/userinfo (Bearer access_token)

Always verify state matches the value you generated, and always perform the code exchange from your backend — the client secret never ships to a browser.

You declare data fields in this portal (app → Data access), not in the authorize URL. The consent screen renders each field with its tag and your reason:

  • Required fields are pre-checked and locked — a user who proceeds grants them. Use for data your app cannot function without.
  • Optional fields are pre-checked but can be unticked. Your app must handle their absence gracefully.

The granted set is embedded in the access token and re-checked live against consents on every call. userinfo simply omits fields the user declined — code defensively.

JavaScript SDK

<script src="https://api.extremextechnology.com/one/sdk/exone.js"></script>
<script>
  exONE.init({
    clientId: "exone_xxxxxxxxxxxx",
    redirectUri: "https://app.yourco.com/auth/callback",
  });

  // Start sign-in anywhere in your app:
  exONE.auth.login();

  // After your backend exchanged the code:
  exONE.auth.setTokens({ access_token, refresh_token });

  // Per-app user data (requires the "attributes" field):
  await exONE.data.write({ high_score: 9001, theme: "dark" });
  const all = await exONE.data.list();
  const one = await exONE.data.read("high_score");
  await exONE.data.remove("theme");
</script>

Mobile & native apps

Apps that ship to user devices (Android, iOS, desktop) are public clients — an APK or IPA can be decompiled, so the client secret must never be embedded in them. The pattern on every platform below is the same:

  1. Open the authorize URL in the system browser (Custom Tabs / ASWebAuthenticationSession), never an embedded WebView — users must see the real one.extremextechnology.com origin.
  2. Receive the callback on an HTTPS app link / universal link you registered as a redirect URL (e.g. https://app.yourco.com/auth/callback). Custom schemes like yourapp:// are not accepted.
  3. Send the code to your backend, which holds the secret, performs the token exchange, and returns your own session (or the tokens) to the app.

The backend half is identical on every stack — any of the server snippets below (Java, Ktor, Node from the guides) can serve any client platform.

Java (server-side)

Plain java.net.http — works the same in Spring Boot, Quarkus, or a servlet:

// 1. Redirect the user's browser to the authorize URL (see OAuth flow above).

// 2. In your callback controller, exchange the code:
HttpClient http = HttpClient.newHttpClient();
String body = """
  {"grant_type":"authorization_code",
   "code":"%s",
   "client_id":"%s",
   "client_secret":"%s",
   "redirect_uri":"https://app.yourco.com/auth/callback"}
  """.formatted(code, System.getenv("EXONE_CLIENT_ID"), System.getenv("EXONE_CLIENT_SECRET"));

HttpRequest tokenReq = HttpRequest.newBuilder(URI.create("https://api.extremextechnology.com/one/oauth/token"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();
JsonNode tokens = mapper.readTree(http.send(tokenReq, BodyHandlers.ofString()).body());

// 3. Fetch the profile:
HttpRequest infoReq = HttpRequest.newBuilder(URI.create("https://api.extremextechnology.com/one/oauth/userinfo"))
    .header("Authorization", "Bearer " + tokens.get("access_token").asText())
    .build();
JsonNode user = mapper.readTree(http.send(infoReq, BodyHandlers.ofString()).body());
String sub = user.get("sub").asText();   // stable user id — key your accounts on this

Kotlin / Android

Launch the consent screen in a Chrome Custom Tab and catch the app-link callback:

// build.gradle: implementation("androidx.browser:browser:1.8.0")

val state = UUID.randomUUID().toString()
prefs.edit { putString("oauth_state", state) }

val url = Uri.parse("https://one.extremextechnology.com/oauth/authorize").buildUpon()
    .appendQueryParameter("client_id", BuildConfig.EXONE_CLIENT_ID) // id only — never the secret
    .appendQueryParameter("redirect_uri", "https://app.yourco.com/auth/callback")
    .appendQueryParameter("state", state)
    .build()
CustomTabsIntent.Builder().build().launchUrl(context, url)

// AndroidManifest.xml — the app link that brings the user back:
// <intent-filter android:autoVerify="true">
//   <action android:name="android.intent.action.VIEW" />
//   <category android:name="android.intent.category.DEFAULT" />
//   <category android:name="android.intent.category.BROWSABLE" />
//   <data android:scheme="https" android:host="app.yourco.com" android:path="/auth/callback" />
// </intent-filter>

// In the activity that receives the link:
val code = intent.data?.getQueryParameter("code")
val returned = intent.data?.getQueryParameter("state")
if (code != null && returned == prefs.getString("oauth_state", null)) {
    // Hand the code to YOUR backend; it exchanges it and returns your app session.
    api.completeLogin(code)
}

Jetpack Compose

Same flow, wrapped in Compose state — the sign-in button and a session holder:

@Composable
fun SignInWithOneButton(viewModel: AuthViewModel) {
    val context = LocalContext.current
    Button(onClick = { viewModel.startLogin(context) }) {
        Text("Sign in with ExtremeX ONE")
    }
}

class AuthViewModel(private val api: BackendApi) : ViewModel() {
    val session = MutableStateFlow<Session?>(null)

    fun startLogin(context: Context) {   // opens the Custom Tab (previous section)
        launchAuthorize(context)
    }

    /** Called from the deep-link activity with the returned code. */
    fun completeLogin(code: String) = viewModelScope.launch {
        session.value = api.completeLogin(code)   // backend exchanges code → session
    }
}

Kotlin Multiplatform (KMP)

Keep the flow logic and backend calls in commonMain with Ktor; only opening the browser is platform-specific:

// commonMain
class OneAuth(private val client: HttpClient, private val backend: String) {
    fun authorizeUrl(state: String) =
        "https://one.extremextechnology.com/oauth/authorize" +
        "?client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&state=$state"

    suspend fun completeLogin(code: String): Session =
        client.post("$backend/auth/exchange") {   // your server does the secret exchange
            contentType(ContentType.Application.Json)
            setBody(mapOf("code" to code))
        }.body()
}

// expect/actual — the only per-platform piece:
expect fun openBrowser(url: String)
// androidMain → CustomTabsIntent.launchUrl(...)
// iosMain     → ASWebAuthenticationSession (see Swift section)
// desktopMain → Desktop.getDesktop().browse(URI(url))

Flutter

// pubspec.yaml: flutter_web_auth_2: ^4.0.0  (uses Custom Tabs / ASWebAuthenticationSession)

final state = base64UrlEncode(List<int>.generate(16, (_) => Random.secure().nextInt(256)));

final result = await FlutterWebAuth2.authenticate(
  url: Uri.parse('https://one.extremextechnology.com/oauth/authorize').replace(queryParameters: {
    'client_id': clientId,                       // id only — the secret stays server-side
    'redirect_uri': 'https://app.yourco.com/auth/callback',
    'state': state,
  }).toString(),
  callbackUrlScheme: 'https',                    // app link / universal link
);

final uri = Uri.parse(result);
if (uri.queryParameters['state'] != state) throw Exception('state mismatch');

// Exchange on YOUR backend, get your own session back:
final session = await http.post(
  Uri.parse('https://api.yourco.com/auth/exchange'),
  headers: {'Content-Type': 'application/json'},
  body: jsonEncode({'code': uri.queryParameters['code']}),
);

Swift / iOS

import AuthenticationServices

let state = UUID().uuidString
var comps = URLComponents(string: "https://one.extremextechnology.com/oauth/authorize")!
comps.queryItems = [
    .init(name: "client_id", value: clientId),   // id only — never ship the secret
    .init(name: "redirect_uri", value: "https://app.yourco.com/auth/callback"),
    .init(name: "state", value: state),
]

let session = ASWebAuthenticationSession(
    url: comps.url!,
    callback: .https(host: "app.yourco.com", path: "/auth/callback")  // universal link
) { callbackURL, error in
    guard let url = callbackURL,
          let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems,
          items.first(where: { $0.name == "state" })?.value == state,
          let code = items.first(where: { $0.name == "code" })?.value
    else { return }

    // Hand the code to YOUR backend for the exchange:
    Task {
        let session = try await backend.completeLogin(code: code)
    }
}
session.presentationContextProvider = self
session.start()

Other platforms

  • React Native / Expoexpo-auth-session or react-native-app-auth pointed at the authorize URL; exchange the code on your backend exactly as above.
  • Desktop (Electron, Tauri, JavaFX, .NET) — open the authorize URL in the default browser and listen on http://localhost:<port>/callback, which is an accepted redirect URL for development; production desktop apps should still exchange the code through your backend.
  • Server-rendered web (PHP, Django, Rails, Go, …) — these are confidential clients: run the whole flow server-side as in the Java example; any HTTP client works, there is nothing platform-specific.
  • CLI tools — print the authorize URL, listen on http://localhost:<port>/callback, then exchange via your backend or (for internal tools) with the secret from the machine's environment.

Whatever the platform: the authorize URL, the consent screen, and the token/userinfo endpoints never change — only how you open a browser and receive the callback differs.

GET /oauth/authorize

GET https://one.extremextechnology.com/oauth/authorize
  ?client_id=…        required — your client ID
  &redirect_uri=…     required — must exactly match a registered redirect URL
  &state=…            recommended — anti-CSRF value echoed back to you
  &scope=…            legacy only — apps with a data-fields manifest ignore it

POST /oauth/token

POST https://api.extremextechnology.com/one/oauth/token
Content-Type: application/json

// Exchange an authorization code:
{
  "grant_type": "authorization_code",
  "code": "…",
  "client_id": "exone_xxxxxxxxxxxx",
  "client_secret": "…",
  "redirect_uri": "https://app.yourco.com/auth/callback"
}
→ { "token_type": "Bearer", "access_token": "…", "expires_in": 3600,
    "scope": "name email", "refresh_token": "…" }   // refresh only with "offline"

// Refresh (tokens rotate — store the new refresh_token):
{
  "grant_type": "refresh_token",
  "refresh_token": "…",
  "client_id": "…",
  "client_secret": "…"
}

GET /oauth/userinfo

GET https://api.extremextechnology.com/one/oauth/userinfo
Authorization: Bearer <access_token>

→ 200 {
  "sub": "9f1c…",            // stable user id — always present
  "name": "Ada Lovelace",    // if granted
  "username": "ada",         // if granted
  "avatar_url": "https://…", // if granted
  "email": "[email protected]" // if granted
}
→ 403 { "error": "access_revoked" }   // user disconnected your app

App data API

With the attributes field granted, your app gets a private key-value store per user, namespaced to your app ID — no application can read another's keys. Values are encrypted at rest (AES-256-GCM, a key derived per app) and decrypted only for your app's requests with an authenticated user token. Declare variables and their types in the app's Create data tab — writes to declared variables are type-checked (400 Type mismatch on violation); undeclared keys stay free-form.

GET    https://api.extremextechnology.com/one/data            → list all keys
GET    https://api.extremextechnology.com/one/data?key=k      → read one key
PUT    https://api.extremextechnology.com/one/data            → { "k": v, … } write/merge
DELETE https://api.extremextechnology.com/one/data?key=k      → remove a key
(all with Authorization: Bearer <access_token>)

Errors

  • invalid_client (401) — client ID/secret pair is wrong, or the app is paused.
  • invalid_grant (400) — code expired (5 min), already used, or redirect URI mismatch.
  • invalid_token (401) — access token missing, malformed, or expired (1 h). Refresh or re-authorize.
  • access_revoked (403) — the user disconnected your app. Delete stored tokens and re-run the flow.
  • insufficient_scope (403) — the call needs a field the user didn't grant.

Security practices

  • Keep the client secret in server-side configuration only; rotate it from the Credentials tab if it may have leaked.
  • Register exact redirect URLs — no wildcards. Use localhost URLs only for development.
  • Always send and verify state.
  • Treat sub as the user's primary key; emails can change.
  • Request the minimum data set and mark fields optional wherever possible — it measurably improves consent rates.

Going live

  1. Production redirect URLs registered (HTTPS).
  2. Data-access reasons reviewed — clear, honest, specific.
  3. Secret stored in a secrets manager; not in the repo.
  4. access_revoked and declined-optional-field paths tested.
  5. Monitoring reviewed on your dashboard after launch.