@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yackey-labs/yauth-ui-solidjs",
3
- "version": "0.12.5",
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.12.5",
27
- "@yackey-labs/yauth-shared": "0.12.5"
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: string;
8
+ clientId?: string;
9
+ /** Scope identifiers the client requested (from the consent payload). */
7
10
  scopes?: string[];
8
- redirectUri: string;
9
- responseType: string;
10
- codeChallenge: string;
11
- codeChallengeMethod: string;
12
- state?: string;
13
- /** Called when user submits their consent decision */
14
- onSubmit?: (approved: boolean) => void;
15
- /** Called on error */
16
- onError?: (error: Error) => void;
17
- /** Auth API base URL (e.g. "/api/auth") */
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}/authorize`, {
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
- client_id: props.clientId,
37
- redirect_uri: props.redirectUri,
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
- if (response.redirected) {
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
- // If the response contains a redirect URL in the Location header
60
- const location = response.headers.get("Location");
61
- if (location) {
62
- window.location.href = location;
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 error = err instanceof Error ? err : new Error(String(err));
69
- setError(error.message);
70
- props.onError?.(error);
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 = () => props.clientName ?? props.clientId;
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-center gap-2 text-sm">
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>{scope}</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
+ }