@maiguard-hq/id
v1.2.0
Published
The drop-in Continue with MaiGuard ID button and OIDC client for the browser
Maintainers
Readme
@maiguard-hq/id
"Continue with MaiGuard ID" as a drop-in button. One script tag, one call, a callback with the user.
<script src="https://cdn.maiguard.com/sdk/v1/maiguard-id.min.js"></script>
<div id="maiguard-btn"></div>
<script>
MaiGuardID.init({ clientId: "mgid_…", redirectUri: location.origin + "/auth/callback" });
MaiGuardID.renderButton("#maiguard-btn", {
onSuccess: ({ code, verifier }) =>
fetch("/auth/callback", { method: "POST", body: JSON.stringify({ code, verifier }) }),
});
</script>No build step, no dependencies, 3.6 KB gzipped. With a bundler:
npm install @maiguard-hq/idimport { init, renderButton } from "@maiguard-hq/id";
init({ clientId: "mgid_…", redirectUri: window.location.origin + "/auth/callback" });
renderButton("#maiguard-btn", { theme: "brand", size: "large", onSuccess });Two modes
Popup, code to your backend (default). The consent screen posts a one-time code back to your page; you
hand it and the PKCE verifier to your server, which exchanges them with the client secret and mints your
own session. The browser never holds a durable credential.
Send both. The SDK always requests with a code_challenge, so the token endpoint always requires the
matching code_verifier, confidential clients included. A code sent without it cannot be spent. @maiguard-hq/id-node
is the other half.
PKCE in the browser (exchange: true). For an SPA with no backend. Legal because MaiGuard ID accepts
public clients, but the tokens then live in that tab's memory and nowhere else. Never in localStorage.
Before it works
Add your site's origin to the app's Allowed origins in the MaiGuard ID developer console. It defaults
to the origins of your redirect URIs, so it is usually already right. Popup sign-in from an origin that is
not on the list is refused with ORIGIN_NOT_ALLOWED, and the error names the origins that would work.
Key the account on sub
sub identifies the person. email and name describe the selected profile: when someone signs in
as a business, they are the business's support address and legal name, identical for every member of that
business. Keying an account on the email merges colleagues into one another.
Use the email to display, and to offer a confirmed link to an existing account. Never a silent merge.
Options
| Option | Values |
| --- | --- |
| theme | brand (default), dark, light, outline |
| size | small, medium, large |
| text | continue_with, signin_with, signup_with |
| shape | rounded, pill |
| width | a number of pixels, or "full" |
| mode | popup (default) or redirect |
| carry | Your own JSON, returned by parseRedirect after a redirect |
| exchange | true to exchange in the browser instead of returning a code |
scope may also be a function returning a promise, for apps whose requested fields are decided at
click time. In redirect mode nothing is opened, so awaiting before navigating is safe; in popup mode the
window is opened first and navigated after.
The button in redirect mode
renderButton("#maiguard-btn", {
mode: "redirect",
carry: { returnTo: "/dashboard" },
scope: async () => fetchRequiredFields(),
});Use it wherever a popup cannot post its result back, most notably when a native app claims your authorize
URL as a universal link. Pair it with storage: "local" on init. There is no onSuccess in this mode:
the page is leaving, and parseRedirect() on your callback route picks it up.
Redirect mode
signIn({ mode: "redirect" }) navigates instead of opening a popup, for embedded webviews and anywhere
popups are unreliable. Call parseRedirect() on the way back.
init({ clientId: "mgid_…", redirectUri: location.origin + "/auth/callback" });
// Starting:
signIn({ mode: "redirect", carry: { returnTo: "/dashboard" } });
// On your callback page:
const { code, verifier, carry } = parseRedirect();
postToYourBackend({ code, verifier });
navigate(carry.returnTo);Keeping your own data across the redirect
carry is any JSON you like. It is stored next to the verifier and handed straight back, which is how you
remember where the user was going, or which of several buttons they pressed, without smuggling it through
state. It never leaves the browser and is never sent to MaiGuard, so do not put anything there that your
server has to trust.
If the code can come back in a different tab
The handoff lives in sessionStorage by default: per-tab, dies with the tab, narrowest blast radius.
Switch to localStorage when the callback can land in a different tab from the one that started:
init({
clientId: "mgid_…",
redirectUri: location.origin + "/auth/callback",
storage: "local", // default "session"
storageTtlMs: 600000, // default 10 minutes
});The case this exists for is a native app claiming your authorize URL as a universal link. Tapping the
button hands the flow to the app rather than navigating the tab, and the app returns the code by opening a
new one. sessionStorage is invisible there, so the callback fails its state check every time on mobile
while working perfectly on desktop, which makes it a miserable bug to chase.
The handoff is single-use and short-lived either way. It is cleared the moment parseRedirect reads it,
and refused past storageTtlMs even if clearing was missed.
Asking for different scopes per sign-in
signIn({ scope }) overrides the scope given to init for that call, for apps whose requested fields are
decided at sign-in time rather than at startup.
const scope = await fetchRequiredFields(); // e.g. "openid profile email doc:CAC"
signIn({ mode: "redirect", scope });The button is inline-styled so it looks the same inside whatever CSS your page already has. Restyling it past these options defeats the point: a sign-in badge is a trust signal, and forty slightly different versions of it teach people that the badge means nothing.
API
| Call | Purpose |
| --- | --- |
| init({ clientId, redirectUri, issuer?, scope? }) | Configure. Fetches discovery once. |
| renderButton(target, options) | Render the button and wire the ceremony |
| signIn(options?) | Programmatic, for a custom trigger |
| parseRedirect(href?) | Redirect mode: read and validate the callback URL |
| exchange({ code, verifier }) | Exchange a code in the browser |
Ships TypeScript declarations, plus ESM, CJS, UMD and IIFE builds.
Errors
Every failure reaches onError as an error with a code.
| Code | Meaning |
| --- | --- |
| ORIGIN_NOT_ALLOWED | This page's origin is not on the app's allowlist |
| popup_blocked | The browser refused the window, almost always because sign-in started outside a click |
| popup_closed | The holder closed the window. Not worth shouting about; the button resets |
| access_denied | The holder declined on the consent screen |
| missing_state | Your half of the handoff is gone: expired, interrupted, or already used. Retrying works |
| state_mismatch | The two halves disagree. Retrying will not fix it, and this is the one worth treating as suspicious |
| token_exchange_failed | Usually a mismatched redirect URI, or a code already spent |
"The button does nothing" is nearly always a blocked popup. renderButton opens the window synchronously
inside the click, so this only appears if you call signIn() yourself after an await.
Any backend language
@maiguard-hq/id-node is a convenience for one ecosystem, not a requirement. MaiGuard ID is a standard
OpenID Connect provider, so the button above works with any server that can make an HTTP request. Point a
conforming OIDC library at the issuer and it configures itself:
https://api.maiguard.com/idleague/oauth2-client (PHP), authlib (Python), omniauth_openid_connect (Ruby), coreos/go-oidc (Go),
Spring Security (Java), Microsoft.AspNetCore.Authentication.OpenIdConnect (.NET) all work unchanged.
The server half is one POST and one signature check:
POST /v1/oauth/token
grant_type=authorization_code&code=…&code_verifier=…
&redirect_uri=…&client_id=…&client_secret=…Configure client_secret_post. MaiGuard ID advertises client_secret_post and none, and does not
accept client_secret_basic. Several libraries default to Basic auth and fail with an error naming none of
this.
Two rules matter more than the plumbing, and no generic library enforces either: verify the id_token
against the JWKS before trusting a claim, and key the account on sub, never on the email.
Related
@maiguard-hq/id-react: the same button as a component@maiguard-hq/id-node: server-side exchange andid_tokenverification
Docs
https://docs.maiguard.com/maiguard-id-sdk
MIT
