mbs-sso-js 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -2
- package/dist/browser.d.ts +13 -0
- package/dist/browser.js +54 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +41 -0
- package/dist/react-native.d.ts +10 -0
- package/dist/react-native.js +62 -0
- package/dist/react.d.ts +8 -0
- package/dist/react.js +49 -0
- package/dist/server.d.ts +39 -0
- package/dist/server.js +93 -0
- package/examples/index.html +19 -0
- package/examples/server.mjs +94 -0
- package/package.json +54 -4
package/README.md
CHANGED
|
@@ -1,3 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mbs-sso-js 0.1.0
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Launch MBS SSO from HTML, React or React Native while keeping OAuth credentials on your application backend. MBS hosts login, enablement, NINAuth, OTP, password setup and consent. Opening a browser is not proof of authentication.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
The package is **not published in the npm registry**. Install the hosted release:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install https://mbs.d3troit.dev/sdk/mbs-sso-js-0.1.0.tgz
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For local development, run `pnpm sdk:build` in the SSO repository, then install its `public/sdk/mbs-sso-js-0.1.0.tgz` file. Node helpers require Node 20+. React and React Native are optional peers; install them only for the adapter you use.
|
|
14
|
+
|
|
15
|
+
## HTML
|
|
16
|
+
|
|
17
|
+
See `examples/index.html`. Load the hosted browser script, then call `MbsSso.mountButton(elementOrSelector, { startUrl, label, className, style, disabled, onError })`. It returns `{ button, destroy }`. `destroy()` removes the mounted button and its listener. Custom CSS can supply focus and hover styles; native browser focus indicators remain enabled.
|
|
18
|
+
|
|
19
|
+
## React
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { MbsSsoButton } from "mbs-sso-js/react";
|
|
23
|
+
<MbsSsoButton startUrl="/auth/mbs/login" className="my-button" style={{ borderRadius: 24 }}>
|
|
24
|
+
Continue with MBS
|
|
25
|
+
</MbsSsoButton>;
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Standard button attributes, custom children, disabled state, inline styles and `onError` are supported. `onClick` can cancel launch using `event.preventDefault()`. For your own button, use `startSso({ startUrl })` from `mbs-sso-js`.
|
|
29
|
+
|
|
30
|
+
## React Native
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { MbsSsoButton } from "mbs-sso-js/react-native";
|
|
34
|
+
<MbsSsoButton startUrl="https://your-app.example/auth/mbs/login" label="Continue with MBS" />;
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Uses `Linking.openURL` to open the system browser. Use an absolute HTTPS start URL. Supports `style`, `textStyle`, disabled state, `onError`, and an injectable `openUrl` function for platform authentication-session integration. Your app must configure its universal/app links and its own authenticated session handoff. A browser cookie does not automatically authenticate a native API client. The backend callback must finish state validation and token exchange first; then issue your application's short-lived, single-use handoff ticket, validate/redeem it on your backend, and establish the native app session. Never put MBS access tokens or client secrets in a deep link. The SDK does not implement or pretend to provide that application-specific backend ticket endpoint.
|
|
38
|
+
|
|
39
|
+
## Backend start and callback
|
|
40
|
+
|
|
41
|
+
`startUrl` is **your application's backend route**, not the MBS enrollment page. Implement `/auth/mbs/login` and `/auth/mbs/enablement` using the complete Express example in `examples/server.mjs`.
|
|
42
|
+
|
|
43
|
+
1. Obtain a client ID and secret from the MBS team. Register your exact callback URL.
|
|
44
|
+
2. Set `MBS_SSO_BASE_URL=https://mbs.d3troit.dev`, `MBS_SSO_CLIENT_ID`, `MBS_SSO_CLIENT_SECRET`, `MBS_SSO_REDIRECT_URI`, and a strong `SESSION_SECRET` on your backend.
|
|
45
|
+
3. `createAuthorizationRequest()` creates random state, nonce and S256 PKCE values. Persist the returned transaction in your server session before redirecting to its `url` (`GET /oauth/authorize`).
|
|
46
|
+
4. In your registered callback, atomically consume the pending transaction. `exchangeAuthorizationCode()` validates its lifetime, callback origin/path, unique state, error and code, then sends a form POST to `/oauth/token` with the original redirect URI, verifier and client credentials.
|
|
47
|
+
5. Call `/oauth/userinfo` server-side and create your own application session. Tokens stay server-side. The helper does not verify an ID-token signature; do not use unverified ID-token claims to authenticate users. Use user info as in the example, or a configured OIDC library for ID-token verification.
|
|
48
|
+
|
|
49
|
+
The Express example uses a development memory session store and refuses production mode until configured. For deployment, use a persistent shared session store, a session lock/atomic transaction consumption for concurrent callbacks, HTTPS cookies and explicit trusted proxy configuration. Its default session transaction is one pending journey per browser session; starting a second replaces the first.
|
|
50
|
+
|
|
51
|
+
## Backend compatibility and versioning
|
|
52
|
+
|
|
53
|
+
The browser adapters know only your start URL. MBS backend improvements remain available in the hosted journey without an SDK update. Changes to the public authorization/token contract may require a new server helper release. Pin the SDK version and test against your registered client. Endpoint behavior is covered by contract tests; a full live sign-in still requires provisioned MBS credentials and NINAuth configuration.
|
|
54
|
+
|
|
55
|
+
Native browser launch and return-link configuration require device validation; this release has not been tested in an iOS/Android emulator.
|
|
56
|
+
|
|
57
|
+
## Publish to the npm registry
|
|
58
|
+
|
|
59
|
+
Run from the SSO repository root:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
pnpm sdk:build
|
|
63
|
+
cd packages/mbs-sso-js
|
|
64
|
+
npm login
|
|
65
|
+
npm publish --dry-run
|
|
66
|
+
npm publish --access public
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Complete npm's browser login and publishing verification using your own account. The prepublishOnly hook rebuilds the SDK and runs the repository checks and tests. The files allowlist publishes dist, examples and the README. Review the dry-run file list before publishing.
|
|
70
|
+
|
|
71
|
+
The unscoped name `mbs-sso-js` must be available or owned by your npm account. If it is not, change the package name to `@YOUR_NPM_SCOPE/mbs-sso-js` and update the example imports and installation instructions accordingly before release. Confirm your organization's ownership and intended license before publishing; this repository does not choose a license for you.
|
|
72
|
+
|
|
73
|
+
After successful publication, consumers can install `npm install mbs-sso-js` (or the scoped name you chose). Update the hosted documentation and release metadata only after verifying the registry release. For subsequent releases, increment the SDK package version, update the pinned SDK guide/download references, rebuild and publish; npm does not allow overwriting a published name/version pair.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { StartOptions } from "./index.js";
|
|
2
|
+
export { startSso, resolveStartUrl } from "./index.js";
|
|
3
|
+
export interface MountOptions extends StartOptions {
|
|
4
|
+
label?: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
style?: Partial<CSSStyleDeclaration>;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
onError?: (error: unknown) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function mountButton(
|
|
11
|
+
target: string | HTMLElement,
|
|
12
|
+
options: MountOptions,
|
|
13
|
+
): { button: HTMLButtonElement; destroy(): void };
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { startSso, resolveStartUrl, buttonStyle } from "./index.js";
|
|
2
|
+
export { startSso, resolveStartUrl };
|
|
3
|
+
|
|
4
|
+
export function mountButton(
|
|
5
|
+
target,
|
|
6
|
+
{
|
|
7
|
+
startUrl,
|
|
8
|
+
label = "Continue with MBS",
|
|
9
|
+
className = "",
|
|
10
|
+
style = {},
|
|
11
|
+
disabled = false,
|
|
12
|
+
onError,
|
|
13
|
+
openUrl,
|
|
14
|
+
} = {},
|
|
15
|
+
) {
|
|
16
|
+
const container = typeof target === "string" ? document.querySelector(target) : target;
|
|
17
|
+
if (!container) throw new Error("MBS button container was not found.");
|
|
18
|
+
resolveStartUrl(startUrl, window.location.href);
|
|
19
|
+
const button = document.createElement("button");
|
|
20
|
+
button.type = "button";
|
|
21
|
+
button.textContent = label;
|
|
22
|
+
button.className = className;
|
|
23
|
+
button.disabled = disabled;
|
|
24
|
+
Object.assign(button.style, buttonStyle, style);
|
|
25
|
+
const status = document.createElement("span");
|
|
26
|
+
status.setAttribute("role", "alert");
|
|
27
|
+
status.style.display = "block";
|
|
28
|
+
status.style.fontSize = "14px";
|
|
29
|
+
const click = async () => {
|
|
30
|
+
if (button.disabled) return;
|
|
31
|
+
button.disabled = true;
|
|
32
|
+
button.setAttribute("aria-busy", "true");
|
|
33
|
+
status.textContent = "";
|
|
34
|
+
try {
|
|
35
|
+
await startSso({ startUrl, openUrl });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
status.textContent = "Could not open MBS SSO. Please try again.";
|
|
38
|
+
onError?.(error);
|
|
39
|
+
} finally {
|
|
40
|
+
button.disabled = disabled;
|
|
41
|
+
button.removeAttribute("aria-busy");
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
button.addEventListener("click", click);
|
|
45
|
+
container.append(button, status);
|
|
46
|
+
return {
|
|
47
|
+
button,
|
|
48
|
+
destroy() {
|
|
49
|
+
button.removeEventListener("click", click);
|
|
50
|
+
button.remove();
|
|
51
|
+
status.remove();
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface StartOptions {
|
|
2
|
+
startUrl: string;
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
openUrl?: (url: string) => unknown | Promise<unknown>;
|
|
5
|
+
}
|
|
6
|
+
export declare function resolveStartUrl(startUrl: string, baseUrl?: string): string;
|
|
7
|
+
export declare function startSso(options: StartOptions): Promise<void>;
|
|
8
|
+
export declare const buttonStyle: Readonly<Record<string, string>>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Resolve a customer-owned backend start route. No OAuth credentials belong here. */
|
|
2
|
+
export function resolveStartUrl(startUrl, baseUrl) {
|
|
3
|
+
if (typeof startUrl !== "string" || !startUrl.trim())
|
|
4
|
+
throw new Error("Provide your backend startUrl.");
|
|
5
|
+
const url = new URL(startUrl, baseUrl);
|
|
6
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
7
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) {
|
|
8
|
+
throw new Error("startUrl must use HTTPS (HTTP is allowed on localhost).");
|
|
9
|
+
}
|
|
10
|
+
if (url.username || url.password) throw new Error("Do not put credentials in startUrl.");
|
|
11
|
+
for (const key of ["client_secret", "access_token", "id_token", "code_verifier"]) {
|
|
12
|
+
if (url.searchParams.has(key)) throw new Error(`Do not put ${key} in startUrl.`);
|
|
13
|
+
}
|
|
14
|
+
return url.href;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function startSso({ startUrl, openUrl, baseUrl } = {}) {
|
|
18
|
+
const browser = typeof window !== "undefined" ? window : undefined;
|
|
19
|
+
const url = resolveStartUrl(startUrl, baseUrl ?? browser?.location?.href);
|
|
20
|
+
if (openUrl) await openUrl(url);
|
|
21
|
+
else if (browser?.location?.assign) browser.location.assign(url);
|
|
22
|
+
else throw new Error("Provide openUrl outside a browser.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const buttonStyle = Object.freeze({
|
|
26
|
+
display: "inline-flex",
|
|
27
|
+
alignItems: "center",
|
|
28
|
+
justifyContent: "center",
|
|
29
|
+
gap: "8px",
|
|
30
|
+
minHeight: "48px",
|
|
31
|
+
maxWidth: "100%",
|
|
32
|
+
padding: "12px 24px",
|
|
33
|
+
border: "1px solid transparent",
|
|
34
|
+
borderRadius: "8px",
|
|
35
|
+
backgroundColor: "#14ae5c",
|
|
36
|
+
color: "#ffffff",
|
|
37
|
+
font: "inherit",
|
|
38
|
+
fontWeight: "500",
|
|
39
|
+
lineHeight: "1.4",
|
|
40
|
+
cursor: "pointer",
|
|
41
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { PressableProps, StyleProp, TextStyle } from "react-native";
|
|
3
|
+
import type { StartOptions } from "./index.js";
|
|
4
|
+
export interface MbsSsoButtonProps
|
|
5
|
+
extends Omit<PressableProps, "onPress" | "onError">, StartOptions {
|
|
6
|
+
label?: string;
|
|
7
|
+
textStyle?: StyleProp<TextStyle>;
|
|
8
|
+
onError?: (error: unknown) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function MbsSsoButton(props: MbsSsoButtonProps): ReactElement;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createElement, useState } from "react";
|
|
2
|
+
import { Linking, Pressable, Text, View } from "react-native";
|
|
3
|
+
import { startSso } from "./index.js";
|
|
4
|
+
export function MbsSsoButton({
|
|
5
|
+
startUrl,
|
|
6
|
+
baseUrl,
|
|
7
|
+
label = "Continue with MBS",
|
|
8
|
+
style,
|
|
9
|
+
textStyle,
|
|
10
|
+
disabled = false,
|
|
11
|
+
onError,
|
|
12
|
+
openUrl = Linking.openURL,
|
|
13
|
+
...props
|
|
14
|
+
}) {
|
|
15
|
+
const [pending, setPending] = useState(false);
|
|
16
|
+
const [error, setError] = useState("");
|
|
17
|
+
const onPress = async () => {
|
|
18
|
+
if (disabled || pending) return;
|
|
19
|
+
setPending(true);
|
|
20
|
+
setError("");
|
|
21
|
+
try {
|
|
22
|
+
await startSso({ startUrl, openUrl, baseUrl });
|
|
23
|
+
} catch (reason) {
|
|
24
|
+
setError("Could not open MBS SSO. Please try again.");
|
|
25
|
+
onError?.(reason);
|
|
26
|
+
} finally {
|
|
27
|
+
setPending(false);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
return createElement(
|
|
31
|
+
View,
|
|
32
|
+
null,
|
|
33
|
+
createElement(
|
|
34
|
+
Pressable,
|
|
35
|
+
{
|
|
36
|
+
...props,
|
|
37
|
+
onPress,
|
|
38
|
+
disabled: disabled || pending,
|
|
39
|
+
accessibilityRole: "button",
|
|
40
|
+
accessibilityLabel: label,
|
|
41
|
+
accessibilityState: { disabled: disabled || pending, busy: pending },
|
|
42
|
+
style: (pressState) => [
|
|
43
|
+
{
|
|
44
|
+
backgroundColor: "#14ae5c",
|
|
45
|
+
borderRadius: 8,
|
|
46
|
+
paddingVertical: 12,
|
|
47
|
+
paddingHorizontal: 24,
|
|
48
|
+
alignItems: "center",
|
|
49
|
+
opacity: disabled || pending ? 0.5 : 1,
|
|
50
|
+
},
|
|
51
|
+
typeof style === "function" ? style(pressState) : style,
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
createElement(
|
|
55
|
+
Text,
|
|
56
|
+
{ style: [{ color: "#ffffff", fontSize: 16, fontWeight: "500" }, textStyle] },
|
|
57
|
+
label,
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
error ? createElement(Text, { accessibilityRole: "alert" }, error) : null,
|
|
61
|
+
);
|
|
62
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, ReactElement } from "react";
|
|
2
|
+
import type { StartOptions } from "./index.js";
|
|
3
|
+
export interface MbsSsoButtonProps
|
|
4
|
+
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onError">, StartOptions {
|
|
5
|
+
label?: string;
|
|
6
|
+
onError?: (error: unknown) => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function MbsSsoButton(props: MbsSsoButtonProps): ReactElement;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createElement, useState } from "react";
|
|
3
|
+
import { buttonStyle, startSso } from "./index.js";
|
|
4
|
+
export function MbsSsoButton({
|
|
5
|
+
startUrl,
|
|
6
|
+
baseUrl,
|
|
7
|
+
children,
|
|
8
|
+
label = "Continue with MBS",
|
|
9
|
+
style,
|
|
10
|
+
disabled,
|
|
11
|
+
onError,
|
|
12
|
+
openUrl,
|
|
13
|
+
onClick,
|
|
14
|
+
...props
|
|
15
|
+
}) {
|
|
16
|
+
const [pending, setPending] = useState(false);
|
|
17
|
+
const [error, setError] = useState("");
|
|
18
|
+
const handleClick = async (event) => {
|
|
19
|
+
onClick?.(event);
|
|
20
|
+
if (event.defaultPrevented || pending || disabled) return;
|
|
21
|
+
setPending(true);
|
|
22
|
+
setError("");
|
|
23
|
+
try {
|
|
24
|
+
await startSso({ startUrl, openUrl, baseUrl });
|
|
25
|
+
} catch (reason) {
|
|
26
|
+
setError("Could not open MBS SSO. Please try again.");
|
|
27
|
+
onError?.(reason);
|
|
28
|
+
} finally {
|
|
29
|
+
setPending(false);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
return createElement(
|
|
33
|
+
"span",
|
|
34
|
+
{ style: { display: "inline-block", maxWidth: "100%" } },
|
|
35
|
+
createElement(
|
|
36
|
+
"button",
|
|
37
|
+
{
|
|
38
|
+
...props,
|
|
39
|
+
type: "button",
|
|
40
|
+
style: { ...buttonStyle, ...style },
|
|
41
|
+
disabled: disabled || pending,
|
|
42
|
+
"aria-busy": pending || undefined,
|
|
43
|
+
onClick: handleClick,
|
|
44
|
+
},
|
|
45
|
+
children ?? label,
|
|
46
|
+
),
|
|
47
|
+
error ? createElement("span", { role: "alert", style: { display: "block" } }, error) : null,
|
|
48
|
+
);
|
|
49
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface Transaction {
|
|
2
|
+
state: string;
|
|
3
|
+
nonce: string;
|
|
4
|
+
verifier: string;
|
|
5
|
+
redirectUri: string;
|
|
6
|
+
expiresAt: number;
|
|
7
|
+
}
|
|
8
|
+
export interface AuthorizationOptions {
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
clientId: string;
|
|
11
|
+
redirectUri: string;
|
|
12
|
+
flow?: "login" | "enablement";
|
|
13
|
+
scope?: string;
|
|
14
|
+
now?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function createAuthorizationRequest(options: AuthorizationOptions): {
|
|
17
|
+
url: string;
|
|
18
|
+
transaction: Transaction;
|
|
19
|
+
};
|
|
20
|
+
export declare function validateCallback(
|
|
21
|
+
callbackUrl: string,
|
|
22
|
+
transaction: Transaction,
|
|
23
|
+
now?: number,
|
|
24
|
+
): string;
|
|
25
|
+
export interface ExchangeOptions {
|
|
26
|
+
baseUrl: string;
|
|
27
|
+
clientId: string;
|
|
28
|
+
clientSecret: string;
|
|
29
|
+
callbackUrl: string;
|
|
30
|
+
transaction: Transaction;
|
|
31
|
+
fetch?: typeof fetch;
|
|
32
|
+
now?: number;
|
|
33
|
+
}
|
|
34
|
+
export declare function exchangeAuthorizationCode(options: ExchangeOptions): Promise<{
|
|
35
|
+
access_token: string;
|
|
36
|
+
id_token?: string;
|
|
37
|
+
expires_in?: number;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { randomBytes, createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { resolveStartUrl } from "./index.js";
|
|
3
|
+
|
|
4
|
+
export function createAuthorizationRequest({
|
|
5
|
+
baseUrl,
|
|
6
|
+
clientId,
|
|
7
|
+
redirectUri,
|
|
8
|
+
flow = "login",
|
|
9
|
+
scope = "profile",
|
|
10
|
+
now = Date.now(),
|
|
11
|
+
}) {
|
|
12
|
+
if (!clientId || !redirectUri) throw new Error("clientId and redirectUri are required.");
|
|
13
|
+
if (!["login", "enablement"].includes(flow)) throw new Error("flow must be login or enablement.");
|
|
14
|
+
const base = resolveStartUrl(baseUrl);
|
|
15
|
+
resolveStartUrl(redirectUri);
|
|
16
|
+
const state = randomBytes(32).toString("base64url");
|
|
17
|
+
const nonce = randomBytes(32).toString("base64url");
|
|
18
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
19
|
+
const url = new URL("/oauth/authorize", base);
|
|
20
|
+
url.search = new URLSearchParams({
|
|
21
|
+
response_type: "code",
|
|
22
|
+
client_id: clientId,
|
|
23
|
+
redirect_uri: redirectUri,
|
|
24
|
+
flow,
|
|
25
|
+
scope,
|
|
26
|
+
state,
|
|
27
|
+
nonce,
|
|
28
|
+
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
29
|
+
code_challenge_method: "S256",
|
|
30
|
+
}).toString();
|
|
31
|
+
return {
|
|
32
|
+
url: url.href,
|
|
33
|
+
transaction: { state, nonce, verifier, redirectUri, expiresAt: now + 600_000 },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function validateCallback(callbackUrl, transaction, now = Date.now()) {
|
|
38
|
+
if (!transaction || !Number.isFinite(transaction.expiresAt) || now >= transaction.expiresAt)
|
|
39
|
+
throw new Error("The SSO transaction is missing or expired.");
|
|
40
|
+
const url = new URL(callbackUrl);
|
|
41
|
+
const expected = new URL(transaction.redirectUri);
|
|
42
|
+
if (url.origin !== expected.origin || url.pathname !== expected.pathname)
|
|
43
|
+
throw new Error("Unexpected callback URL.");
|
|
44
|
+
const states = url.searchParams.getAll("state");
|
|
45
|
+
const actual = Buffer.from(states[0] ?? "");
|
|
46
|
+
const saved = Buffer.from(transaction.state ?? "");
|
|
47
|
+
if (
|
|
48
|
+
states.length !== 1 ||
|
|
49
|
+
!saved.length ||
|
|
50
|
+
actual.length !== saved.length ||
|
|
51
|
+
!timingSafeEqual(actual, saved)
|
|
52
|
+
)
|
|
53
|
+
throw new Error("The callback state does not match.");
|
|
54
|
+
if (url.searchParams.has("error"))
|
|
55
|
+
throw new Error("MBS authorization was declined or could not be completed.");
|
|
56
|
+
const codes = url.searchParams.getAll("code");
|
|
57
|
+
if (codes.length !== 1 || !codes[0])
|
|
58
|
+
throw new Error("The callback is missing a unique authorization code.");
|
|
59
|
+
return codes[0];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function exchangeAuthorizationCode({
|
|
63
|
+
baseUrl,
|
|
64
|
+
clientId,
|
|
65
|
+
clientSecret,
|
|
66
|
+
callbackUrl,
|
|
67
|
+
transaction,
|
|
68
|
+
fetch: request = globalThis.fetch,
|
|
69
|
+
now = Date.now(),
|
|
70
|
+
}) {
|
|
71
|
+
if (!clientId || !clientSecret)
|
|
72
|
+
throw new Error("Server-side clientId and clientSecret are required.");
|
|
73
|
+
const code = validateCallback(callbackUrl, transaction, now);
|
|
74
|
+
const response = await request(new URL("/oauth/token", resolveStartUrl(baseUrl)), {
|
|
75
|
+
method: "POST",
|
|
76
|
+
redirect: "error",
|
|
77
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
78
|
+
body: new URLSearchParams({
|
|
79
|
+
grant_type: "authorization_code",
|
|
80
|
+
code,
|
|
81
|
+
redirect_uri: transaction.redirectUri,
|
|
82
|
+
code_verifier: transaction.verifier,
|
|
83
|
+
client_id: clientId,
|
|
84
|
+
client_secret: clientSecret,
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
if (!response.ok) throw new Error(`MBS token exchange failed (HTTP ${response.status}).`);
|
|
88
|
+
const body = await response.json();
|
|
89
|
+
const tokens = body.data ?? body;
|
|
90
|
+
if (body.error || body.status === false || !tokens.access_token)
|
|
91
|
+
throw new Error("MBS did not return a valid access token.");
|
|
92
|
+
return tokens;
|
|
93
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
6
|
+
<title>Connect with MBS</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="mbs-login"></div>
|
|
10
|
+
<script src="https://mbs.d3troit.dev/sdk/mbs-sso-js-0.1.0.js"></script>
|
|
11
|
+
<script>
|
|
12
|
+
MbsSso.mountButton("#mbs-login", {
|
|
13
|
+
startUrl: "/auth/mbs/login",
|
|
14
|
+
label: "Continue with MBS",
|
|
15
|
+
style: { borderRadius: "24px" },
|
|
16
|
+
});
|
|
17
|
+
</script>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Node 20+, Express and express-session. Run behind HTTPS in production.
|
|
2
|
+
import express from "express";
|
|
3
|
+
import session from "express-session";
|
|
4
|
+
import { createAuthorizationRequest, exchangeAuthorizationCode } from "mbs-sso-js/server";
|
|
5
|
+
|
|
6
|
+
const app = express();
|
|
7
|
+
const production = process.env.NODE_ENV === "production";
|
|
8
|
+
for (const name of [
|
|
9
|
+
"SESSION_SECRET",
|
|
10
|
+
"MBS_SSO_CLIENT_ID",
|
|
11
|
+
"MBS_SSO_CLIENT_SECRET",
|
|
12
|
+
"MBS_SSO_REDIRECT_URI",
|
|
13
|
+
]) {
|
|
14
|
+
if (!process.env[name]) throw new Error(`Set ${name} on the backend.`);
|
|
15
|
+
}
|
|
16
|
+
if (production)
|
|
17
|
+
throw new Error(
|
|
18
|
+
"Configure a shared persistent session store and trusted proxy before deploying this example.",
|
|
19
|
+
);
|
|
20
|
+
app.use(
|
|
21
|
+
session({
|
|
22
|
+
secret: process.env.SESSION_SECRET,
|
|
23
|
+
resave: false,
|
|
24
|
+
saveUninitialized: false,
|
|
25
|
+
cookie: { httpOnly: true, secure: production, sameSite: "lax", maxAge: 600_000 },
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
const baseUrl = process.env.MBS_SSO_BASE_URL ?? "https://mbs.d3troit.dev";
|
|
29
|
+
const redirectUri = process.env.MBS_SSO_REDIRECT_URI;
|
|
30
|
+
const clientId = process.env.MBS_SSO_CLIENT_ID;
|
|
31
|
+
|
|
32
|
+
for (const flow of ["login", "enablement"]) {
|
|
33
|
+
app.get(`/auth/mbs/${flow}`, (req, res, next) => {
|
|
34
|
+
try {
|
|
35
|
+
const { url, transaction } = createAuthorizationRequest({
|
|
36
|
+
baseUrl,
|
|
37
|
+
clientId,
|
|
38
|
+
redirectUri,
|
|
39
|
+
flow,
|
|
40
|
+
});
|
|
41
|
+
req.session.mbs = transaction;
|
|
42
|
+
// Persist state and verifier before leaving the application.
|
|
43
|
+
req.session.save((error) => (error ? next(error) : res.redirect(url)));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
next(error);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
app.get(new URL(redirectUri).pathname, async (req, res, next) => {
|
|
50
|
+
const transaction = req.session.mbs;
|
|
51
|
+
delete req.session.mbs; // Consume the pending transaction before exchange, including failed callbacks.
|
|
52
|
+
req.session.save(async (error) => {
|
|
53
|
+
if (error) return next(error);
|
|
54
|
+
try {
|
|
55
|
+
const callback = new URL(redirectUri);
|
|
56
|
+
callback.search = new URL(req.originalUrl, redirectUri).search;
|
|
57
|
+
const tokens = await exchangeAuthorizationCode({
|
|
58
|
+
baseUrl,
|
|
59
|
+
clientId,
|
|
60
|
+
clientSecret: process.env.MBS_SSO_CLIENT_SECRET,
|
|
61
|
+
callbackUrl: callback.href,
|
|
62
|
+
transaction,
|
|
63
|
+
});
|
|
64
|
+
const response = await fetch(new URL("/oauth/userinfo", baseUrl), {
|
|
65
|
+
headers: { Authorization: `Bearer ${tokens.access_token}`, Accept: "application/json" },
|
|
66
|
+
redirect: "error",
|
|
67
|
+
});
|
|
68
|
+
if (!response.ok) throw new Error("User info could not be retrieved.");
|
|
69
|
+
const body = await response.json();
|
|
70
|
+
const profile = body.data ?? body;
|
|
71
|
+
if (body.error || body.status === false || !profile.sub)
|
|
72
|
+
throw new Error("Invalid user info response.");
|
|
73
|
+
req.session.regenerate((regenerateError) => {
|
|
74
|
+
if (regenerateError) return next(regenerateError);
|
|
75
|
+
req.session.user = { sub: profile.sub, entity_id: profile.entity_id };
|
|
76
|
+
req.session.cookie.maxAge = 3_600_000;
|
|
77
|
+
req.session.save((saveError) => (saveError ? next(saveError) : res.redirect("/app")));
|
|
78
|
+
});
|
|
79
|
+
} catch {
|
|
80
|
+
res.status(400).send("Sign-in could not be completed. Start again from your application.");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
app.get("/app", (req, res) => {
|
|
85
|
+
if (!req.session.user) return res.status(401).send("Sign in first.");
|
|
86
|
+
res.type("text").send(`Signed in as ${req.session.user.sub}`);
|
|
87
|
+
});
|
|
88
|
+
app.use((error, req, res, next) => {
|
|
89
|
+
void error;
|
|
90
|
+
void req;
|
|
91
|
+
void next;
|
|
92
|
+
res.status(500).send("SSO is temporarily unavailable.");
|
|
93
|
+
});
|
|
94
|
+
app.listen(Number(process.env.PORT ?? 3000));
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mbs-sso-js",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MBS SSO browser, React and React Native launch buttons with Node.js authorization helpers",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist",
|
|
7
|
+
"examples",
|
|
8
|
+
"README.md"
|
|
9
|
+
],
|
|
10
|
+
"type": "module",
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./react": {
|
|
18
|
+
"types": "./dist/react.d.ts",
|
|
19
|
+
"import": "./dist/react.js"
|
|
20
|
+
},
|
|
21
|
+
"./react-native": {
|
|
22
|
+
"types": "./dist/react-native.d.ts",
|
|
23
|
+
"import": "./dist/react-native.js"
|
|
24
|
+
},
|
|
25
|
+
"./server": {
|
|
26
|
+
"types": "./dist/server.d.ts",
|
|
27
|
+
"import": "./dist/server.js"
|
|
28
|
+
},
|
|
29
|
+
"./browser": {
|
|
30
|
+
"types": "./dist/browser.d.ts",
|
|
31
|
+
"import": "./dist/browser.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"registry": "https://registry.npmjs.org/"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"prepublishOnly": "cd ../.. && pnpm sdk:build && pnpm check && pnpm test"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": ">=18",
|
|
43
|
+
"react-native": ">=0.73"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"react": {
|
|
47
|
+
"optional": true
|
|
48
|
+
},
|
|
49
|
+
"react-native": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=20"
|
|
55
|
+
}
|
|
56
|
+
}
|