@yackey-labs/yauth-ui-solidjs 0.12.5 → 0.13.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/dist/index.js +444 -377
- package/package.json +3 -3
- package/src/components/consent-screen.tsx +56 -40
- package/src/components/oauth-consent-page.tsx +127 -0
- package/src/index.ts +3 -0
- package/src/safe-redirect.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yackey-labs/yauth-ui-solidjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/yackey-labs/yauth"
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@simplewebauthn/browser": "^13.0.0",
|
|
25
25
|
"solid-js": "^1.9.5",
|
|
26
|
-
"@yackey-labs/yauth-client": "0.
|
|
27
|
-
"@yackey-labs/yauth-shared": "0.
|
|
26
|
+
"@yackey-labs/yauth-client": "0.13.0",
|
|
27
|
+
"@yackey-labs/yauth-shared": "0.13.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"happy-dom": "^20.9.0",
|
|
@@ -1,23 +1,41 @@
|
|
|
1
1
|
import { type Component, createSignal, For } from "solid-js";
|
|
2
2
|
import { Show } from "solid-js/web";
|
|
3
|
+
import { isSafeRedirect } from "../safe-redirect";
|
|
3
4
|
|
|
4
5
|
export interface ConsentScreenProps {
|
|
6
|
+
/** Human-readable client name; falls back to clientId. */
|
|
5
7
|
clientName?: string;
|
|
6
|
-
clientId
|
|
8
|
+
clientId?: string;
|
|
9
|
+
/** Scope identifiers the client requested (from the consent payload). */
|
|
7
10
|
scopes?: string[];
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Per-scope human-readable descriptions, keyed by scope name. Mirror your
|
|
13
|
+
* server's `mcpauth.Config.Scopes` catalog here so the consent screen shows
|
|
14
|
+
* plain language instead of raw scope strings. Scopes without an entry fall
|
|
15
|
+
* back to showing the raw identifier.
|
|
16
|
+
*/
|
|
17
|
+
scopeDescriptions?: Record<string, string>;
|
|
18
|
+
/** Signed request id from the GET /oauth/authorize consent payload. */
|
|
19
|
+
requestId: string;
|
|
20
|
+
/** Signed CSRF token from the GET /oauth/authorize consent payload. */
|
|
21
|
+
csrfToken: string;
|
|
22
|
+
/** Auth API base URL (default "/api/auth"). */
|
|
18
23
|
authBaseUrl?: string;
|
|
24
|
+
/** Called after a decision is submitted and no redirect was returned. */
|
|
25
|
+
onResult?: (approved: boolean) => void;
|
|
26
|
+
/** Called on error. */
|
|
27
|
+
onError?: (error: Error) => void;
|
|
19
28
|
}
|
|
20
29
|
|
|
30
|
+
/**
|
|
31
|
+
* ConsentScreen renders the second half of the yauth oauth2-server
|
|
32
|
+
* authorization-code flow. The first half — GET {authBaseUrl}/oauth/authorize
|
|
33
|
+
* with the session cookie — returns a consent payload ({ request_id,
|
|
34
|
+
* csrf_token, scopes, client }); pass those in as props (OAuthConsentPage does
|
|
35
|
+
* this for you). On a decision it POSTs {authBaseUrl}/oauth2/consent with
|
|
36
|
+
* { request_id, csrf_token, approved } and follows the returned redirect_url
|
|
37
|
+
* back to the OAuth client.
|
|
38
|
+
*/
|
|
21
39
|
export const ConsentScreen: Component<ConsentScreenProps> = (props) => {
|
|
22
40
|
const [loading, setLoading] = createSignal(false);
|
|
23
41
|
const [error, setError] = createSignal<string | null>(null);
|
|
@@ -25,55 +43,46 @@ export const ConsentScreen: Component<ConsentScreenProps> = (props) => {
|
|
|
25
43
|
const handleDecision = async (approved: boolean) => {
|
|
26
44
|
setError(null);
|
|
27
45
|
setLoading(true);
|
|
28
|
-
|
|
29
46
|
try {
|
|
30
47
|
const baseUrl = props.authBaseUrl ?? "/api/auth";
|
|
31
|
-
const response = await fetch(`${baseUrl}/
|
|
48
|
+
const response = await fetch(`${baseUrl}/oauth2/consent`, {
|
|
32
49
|
method: "POST",
|
|
33
50
|
headers: { "Content-Type": "application/json" },
|
|
34
51
|
credentials: "include",
|
|
35
52
|
body: JSON.stringify({
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
response_type: props.responseType,
|
|
39
|
-
code_challenge: props.codeChallenge,
|
|
40
|
-
code_challenge_method: props.codeChallengeMethod,
|
|
41
|
-
scope: props.scopes?.join(" "),
|
|
42
|
-
state: props.state,
|
|
53
|
+
request_id: props.requestId,
|
|
54
|
+
csrf_token: props.csrfToken,
|
|
43
55
|
approved,
|
|
44
56
|
}),
|
|
45
57
|
});
|
|
46
58
|
|
|
47
|
-
|
|
48
|
-
window.location.href = response.url;
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
|
|
59
|
+
const body = await response.json().catch(() => null);
|
|
52
60
|
if (!response.ok) {
|
|
53
|
-
const body = await response.json().catch(() => null);
|
|
54
61
|
throw new Error(
|
|
55
62
|
body?.error_description ?? body?.error ?? "Authorization failed",
|
|
56
63
|
);
|
|
57
64
|
}
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
// The server mints the code and returns the redirect URL (both on
|
|
66
|
+
// approval and on denial, which carries error=access_denied).
|
|
67
|
+
if (body?.redirect_url) {
|
|
68
|
+
if (!isSafeRedirect(body.redirect_url)) {
|
|
69
|
+
throw new Error("Blocked unsafe redirect target");
|
|
70
|
+
}
|
|
71
|
+
window.location.href = body.redirect_url;
|
|
63
72
|
return;
|
|
64
73
|
}
|
|
65
|
-
|
|
66
|
-
props.onSubmit?.(approved);
|
|
74
|
+
props.onResult?.(approved);
|
|
67
75
|
} catch (err) {
|
|
68
|
-
const
|
|
69
|
-
setError(
|
|
70
|
-
props.onError?.(
|
|
76
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
77
|
+
setError(e.message);
|
|
78
|
+
props.onError?.(e);
|
|
71
79
|
} finally {
|
|
72
80
|
setLoading(false);
|
|
73
81
|
}
|
|
74
82
|
};
|
|
75
83
|
|
|
76
|
-
const displayName = () =>
|
|
84
|
+
const displayName = () =>
|
|
85
|
+
props.clientName ?? props.clientId ?? "An application";
|
|
77
86
|
|
|
78
87
|
return (
|
|
79
88
|
<div class="mx-auto max-w-md space-y-6 p-6">
|
|
@@ -100,9 +109,9 @@ export const ConsentScreen: Component<ConsentScreenProps> = (props) => {
|
|
|
100
109
|
<ul class="space-y-2">
|
|
101
110
|
<For each={props.scopes}>
|
|
102
111
|
{(scope) => (
|
|
103
|
-
<li class="flex items-
|
|
112
|
+
<li class="flex items-start gap-2 text-sm">
|
|
104
113
|
<svg
|
|
105
|
-
class="h-4 w-4 text-primary"
|
|
114
|
+
class="mt-0.5 h-4 w-4 shrink-0 text-primary"
|
|
106
115
|
fill="none"
|
|
107
116
|
stroke="currentColor"
|
|
108
117
|
viewBox="0 0 24 24"
|
|
@@ -116,7 +125,14 @@ export const ConsentScreen: Component<ConsentScreenProps> = (props) => {
|
|
|
116
125
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
117
126
|
/>
|
|
118
127
|
</svg>
|
|
119
|
-
<span>
|
|
128
|
+
<span>
|
|
129
|
+
{props.scopeDescriptions?.[scope] ?? scope}
|
|
130
|
+
<Show when={props.scopeDescriptions?.[scope]}>
|
|
131
|
+
<code class="ml-1 text-xs text-muted-foreground">
|
|
132
|
+
{scope}
|
|
133
|
+
</code>
|
|
134
|
+
</Show>
|
|
135
|
+
</span>
|
|
120
136
|
</li>
|
|
121
137
|
)}
|
|
122
138
|
</For>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { type Component, createResource, Show } from "solid-js";
|
|
2
|
+
import { ConsentScreen } from "./consent-screen";
|
|
3
|
+
import { isSafeRedirect } from "../safe-redirect";
|
|
4
|
+
|
|
5
|
+
export interface OAuthConsentPageProps {
|
|
6
|
+
/** Auth API base URL (default "/api/auth"). */
|
|
7
|
+
authBaseUrl?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Where to send an unauthenticated user. The current URL (authorize request
|
|
10
|
+
* + query) is appended as `redirect_to` so the flow resumes after login.
|
|
11
|
+
* Default "/login".
|
|
12
|
+
*/
|
|
13
|
+
loginPath?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Per-scope descriptions, keyed by scope name — mirror your server's
|
|
16
|
+
* `mcpauth.Config.Scopes` catalog so users see plain language.
|
|
17
|
+
*/
|
|
18
|
+
scopeDescriptions?: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The consent payload from GET /oauth/authorize. The two yauth servers spell it
|
|
23
|
+
* slightly differently — yauth-go nests `client` and returns a `scopes` array;
|
|
24
|
+
* Rust yauth returns flat `client_id`/`client_name` and a space-delimited
|
|
25
|
+
* `scope` string — so both shapes are optional here and normalized below.
|
|
26
|
+
*/
|
|
27
|
+
interface ConsentPayload {
|
|
28
|
+
client?: { id: string; name?: string };
|
|
29
|
+
client_id?: string;
|
|
30
|
+
client_name?: string;
|
|
31
|
+
scopes?: string[];
|
|
32
|
+
scope?: string;
|
|
33
|
+
csrf_token: string;
|
|
34
|
+
request_id: string;
|
|
35
|
+
redirect_url?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const clientIdOf = (p: ConsentPayload) => p.client?.id ?? p.client_id;
|
|
39
|
+
const clientNameOf = (p: ConsentPayload) => p.client?.name ?? p.client_name;
|
|
40
|
+
const scopesOf = (p: ConsentPayload): string[] =>
|
|
41
|
+
Array.isArray(p.scopes)
|
|
42
|
+
? p.scopes
|
|
43
|
+
: typeof p.scope === "string" && p.scope.length > 0
|
|
44
|
+
? p.scope.split(/\s+/)
|
|
45
|
+
: [];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* OAuthConsentPage is a drop-in route for the SPA path you advertise as the
|
|
49
|
+
* authorization endpoint (e.g. "/authorize" — what `mcpauth.Mount` rewrites
|
|
50
|
+
* authorization_endpoint to). Mount it at that route and it handles the whole
|
|
51
|
+
* browser side of the yauth oauth2-server flow:
|
|
52
|
+
*
|
|
53
|
+
* 1. GET {authBaseUrl}/oauth/authorize{window.location.search} with the
|
|
54
|
+
* session cookie.
|
|
55
|
+
* 2. 401 -> redirect to {loginPath}?redirect_to=<this URL> so the user logs
|
|
56
|
+
* in and comes back.
|
|
57
|
+
* 3. { redirect_url } (consent already granted) -> follow it.
|
|
58
|
+
* 4. consent payload -> render <ConsentScreen> with the signed request_id +
|
|
59
|
+
* csrf_token.
|
|
60
|
+
*
|
|
61
|
+
* Example (@solidjs/router):
|
|
62
|
+
*
|
|
63
|
+
* <Route path="/authorize" component={() =>
|
|
64
|
+
* <OAuthConsentPage scopeDescriptions={SCOPES} />} />
|
|
65
|
+
*/
|
|
66
|
+
export const OAuthConsentPage: Component<OAuthConsentPageProps> = (props) => {
|
|
67
|
+
const baseUrl = () => props.authBaseUrl ?? "/api/auth";
|
|
68
|
+
|
|
69
|
+
const [payload] = createResource<ConsentPayload | null>(async () => {
|
|
70
|
+
const here = window.location.pathname + window.location.search;
|
|
71
|
+
const res = await fetch(`${baseUrl()}/oauth/authorize${window.location.search}`, {
|
|
72
|
+
method: "GET",
|
|
73
|
+
credentials: "include",
|
|
74
|
+
headers: { Accept: "application/json" },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (res.status === 401) {
|
|
78
|
+
const loginPath = props.loginPath ?? "/login";
|
|
79
|
+
window.location.href = `${loginPath}?redirect_to=${encodeURIComponent(here)}`;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const body = (await res.json().catch(() => null)) as ConsentPayload | null;
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
(body as { error_description?: string; error?: string } | null)
|
|
87
|
+
?.error_description ??
|
|
88
|
+
(body as { error?: string } | null)?.error ??
|
|
89
|
+
"Failed to start authorization",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
// Consent already on file: the server returns the redirect directly.
|
|
93
|
+
if (body?.redirect_url) {
|
|
94
|
+
if (!isSafeRedirect(body.redirect_url)) {
|
|
95
|
+
throw new Error("Blocked unsafe redirect target");
|
|
96
|
+
}
|
|
97
|
+
window.location.href = body.redirect_url;
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return body;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Show
|
|
105
|
+
when={payload()}
|
|
106
|
+
fallback={
|
|
107
|
+
<Show when={payload.error}>
|
|
108
|
+
<div class="mx-auto max-w-md p-6 text-sm text-destructive">
|
|
109
|
+
{(payload.error as Error)?.message ?? "Authorization error"}
|
|
110
|
+
</div>
|
|
111
|
+
</Show>
|
|
112
|
+
}
|
|
113
|
+
>
|
|
114
|
+
{(p) => (
|
|
115
|
+
<ConsentScreen
|
|
116
|
+
authBaseUrl={baseUrl()}
|
|
117
|
+
clientId={clientIdOf(p())}
|
|
118
|
+
clientName={clientNameOf(p())}
|
|
119
|
+
scopes={scopesOf(p())}
|
|
120
|
+
scopeDescriptions={props.scopeDescriptions}
|
|
121
|
+
requestId={p().request_id}
|
|
122
|
+
csrfToken={p().csrf_token}
|
|
123
|
+
/>
|
|
124
|
+
)}
|
|
125
|
+
</Show>
|
|
126
|
+
);
|
|
127
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,9 @@ export { AuditDestinationCreate } from "./components/audit-destination-create";
|
|
|
2
2
|
export { AuditDestinationList } from "./components/audit-destination-list";
|
|
3
3
|
export { ChangePasswordForm } from "./components/change-password-form";
|
|
4
4
|
export { ConsentScreen } from "./components/consent-screen";
|
|
5
|
+
export type { ConsentScreenProps } from "./components/consent-screen";
|
|
6
|
+
export { OAuthConsentPage } from "./components/oauth-consent-page";
|
|
7
|
+
export type { OAuthConsentPageProps } from "./components/oauth-consent-page";
|
|
5
8
|
export { DomainClaim } from "./components/domain-claim";
|
|
6
9
|
export { DomainList } from "./components/domain-list";
|
|
7
10
|
export { DomainVerifyStep } from "./components/domain-verify-step";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* isSafeRedirect reports whether a URL is safe to navigate to via
|
|
3
|
+
* `window.location`. It rejects `javascript:`, `data:`, and any other
|
|
4
|
+
* non-HTTP(S) scheme so a malicious registered `redirect_uri` cannot execute
|
|
5
|
+
* script in this origin. Defense in depth — the authorization server also
|
|
6
|
+
* validates the scheme at client registration (OAuth 2.1 BCP §9).
|
|
7
|
+
*/
|
|
8
|
+
export function isSafeRedirect(url: string): boolean {
|
|
9
|
+
try {
|
|
10
|
+
const protocol = new URL(url, window.location.origin).protocol;
|
|
11
|
+
return protocol === "https:" || protocol === "http:";
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|