Building your app

Browser tools

A browser tool is code that runs inside a system the school already uses — Noor, typically — not inside Okta. You write it, it ships inside your app's version, and Okta serves it to the Okta Tools extension in the user's browser. Valid for any integration type: a tool is a capability your app ships, not a kind of app, so nothing gates it — not integrationType, not another switch.

The governing rule: the extension runs it, Okta serves it

This rule changed. Partners used to host a .user.js file and Okta listed the link for the user to install into Tampermonkey or similar. That is no longer a supported path:

  • The user downloads nothing and needs no userscript manager.
  • The Okta Tools extension (Chrome Web Store) is the only thing that runs a tool.
  • You write the body into the script field on the version, and Okta serves it from its own origin, to the extension alone.

Three things follow that were not possible before:

  1. What a school runs is what was published and reviewed. It cannot change under them between reviews.
  2. A code update reaches everyone with no reinstall of anything. Publish a version; the extension picks it up.
  3. No auth wall silently kills auto-update, and no partner URL rots a year later leaving a dead tool nobody notices. Those were the three most common failures of the old path, and hosting removed all of them.

The old shape is still accepted: a published version carrying a url ending in .user.js with no script is neither rejected nor hidden — dropping it would have hidden a tool that works today for tenants who installed it. But do not write new tools that way; none of the three benefits above apply to it.

delivery: webstore is unchanged: an extension you distribute through the Chrome Web Store, where the URL is the whole product.

Declaring one

In the portal, on your app's page: Integration tab → Browser tools → "Add a tool". The fields:

Field Required Rule
key ^[a-z][a-z0-9-]*$, ≤ 32 chars, unique within your app. Okta addresses the tool as <module>:<key>
name ≤ 80 chars. name_en optional, same limit
description — ≤ 300 chars; description_en likewise
delivery userscript or webstore
script for userscript the tool body, ≤ 128 KB
world — isolated (default) or main. Injected tools only — read the next section before choosing
url for webstore absolute https to the Web Store listing, ≤ 500 chars
icon — absolute https. A broken icon is stripped and the row survives
version — ≤ 20 chars, defaults to 1.0
match for userscript one pattern per line, ≤ 20 patterns, ≤ 200 chars each

At most 10 tools per app. List order is display order for the tenant — your only control over which one they see first.

world: the most dangerous field here

The execution world is a functional choice, not a preference, and its effect only shows at runtime, in the user's browser:

Value What the tool sees
isolated (default) the DOM tree only. Not the page's JavaScript globals or functions
main the page's own world: its globals, its functions, its frameworks

The practical rule: if your tool calls anything the page defines — $find in ASP.NET apps, jQuery, an app's own client object — it needs main. Under isolated those are simply undefined, and it fails with is not a function while everything else looks fine: the tool is enabled, the buttons are drawn, the page works. That is exactly what happened to the Noor export tool, and it is the class of failure users never report, because they don't know what was supposed to happen.

Start at isolated — if your tool only reads and writes the DOM that is enough, and it is the narrower privilege. Raise it to main for this reason and no other.

An unknown value (page, say) drops the whole tool rather than downgrading it to isolated: a silent downgrade ships the exact failure described above.

The cross-checks that drop a row

The partner portal checks these and blocks the save, but know them — they are the most common mistakes:

  1. delivery: userscript with no script. The body travels inside the version, so a tool without one is not a tool. (Sole exception: the legacy shape with a .user.js url.)
  2. script and url on the same row. One source of truth per tool — otherwise there is no telling which body a tenant is running.
  3. delivery: webstore with a script body. Nothing runs it: the Web Store extension is the artifact, so the body here is dead code that reads as live.
  4. delivery: userscript with no match pattern. It is the tenant's only disclosure of which sites your tool touches, and what the extension requests permission for.
  5. delivery: webstore with a url ending in .user.js. A mistyped delivery.

Why we block you instead of accepting and fixing: BuildBrowserToolsBlock drops any row it cannot ship whole. Had we let it through, the form would save successfully, the manifest would come out one tool short, and you would find out from a tenant reporting a tool that never appeared. Failing at the form is the difference between "the portal told me my tool was incomplete" and "my tool vanished silently".

match patterns

Chrome match-pattern syntax: <scheme>://<host><path>.

https://noor.moe.gov.sa/*
https://*.moe.gov.sa/reports/*

Three things people get wrong:

  • * in the scheme means http|https only — not "any scheme".
  • *.example.com includes example.com itself, not only its subdomains.
  • The path is mandatory: https://noor.moe.gov.sa with no / is invalid.

Keep them as narrow as they can be. A broad pattern means a wider permission prompt for the user, and one more reason for them to refuse to run your tool.

The manifest shape

{
  "browser_tools": [
    {
      "key": "noor-helper",
      "name": "مساعد نور",
      "name_en": "Noor Helper",
      "description": "Speeds up grade entry in Noor.",
      "delivery": "userscript",
      "script": "(function () { 'use strict'; /* ... */ })();",
      "world": "main",
      "icon": "https://partner.example/tools/icon.png",
      "version": "1.2",
      "match": ["https://noor.moe.gov.sa/*"]
    }
  ]
}

A ==UserScript== header is not required — the extension does not read it; the declared match and world are what govern injection. Write one only as documentation.

Version-level override

The list is owned at the app level and every version inherits it. A version may carry its own list, from the version editor ("Browser tools" card → "Customise the list for this version").

Know what that means before you do it:

  • An own list beats the app list when the manifest is built. Any tool you add later at the app level will not reach that version.
  • An empty own list is not inheritance, it is an explicit declaration that this version ships no tools at all. Use it to withdraw a tool in one version and keep it in another.
  • Going back: "Return to the app list" on the same card clears the override and restores dynamic inheritance.

And since the body is now part of the version, an override also freezes the tool's code on that version.

Updating a published tool

Edit script, raise version, publish. The extension compares versions and re-fetches the body when they differ, and skips the fetch entirely when nothing changed. Nobody is asked to reinstall the extension or the tool.

Freezing the number freezes the tool on its old body for everyone who enabled it, with no visible symptom to anyone — so raise it on every edit, however small.

What a tool cannot do

A tool never reaches Okta, in either world: it carries no installation token, reads none of your scopes, and cannot call /api/apps/* as your app. world: main widens what it sees of the target page, not what it holds of Okta. If you need data from Okta, that is your app's job, not its tool's.


View the whole guide on one page · ↧ Raw Markdown