@snokam/auth 3.2.0 → 3.2.1

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/CHANGELOG.md CHANGED
@@ -3,6 +3,12 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [3.2.1](https://github.com/snokam/components/compare/@snokam/auth@3.2.0...@snokam/auth@3.2.1) (2026-10-02)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **auth:** harden app sign-in after a security review, and give sign-in one home ([#2543](https://github.com/snokam/components/issues/2543)) ([7100eb7](https://github.com/snokam/components/commit/7100eb79ce278875eafe75228ab496dedc0ae151))
11
+
6
12
  # [3.2.0](https://github.com/snokam/components/compare/@snokam/auth@3.1.1...@snokam/auth@3.2.0) (2026-10-02)
7
13
 
8
14
  ### Features
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@snokam/auth",
3
3
  "description": "Snøkam auth components",
4
- "version": "3.2.0",
4
+ "version": "3.2.1",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
7
7
  "files": [
@@ -25,10 +25,10 @@
25
25
  "@snokam/employees-client": "^1.3.0",
26
26
  "next-auth": "4.24.15",
27
27
  "path-to-regexp": "^8.4.2",
28
- "@snokam/platform-client": "3.1.0",
29
- "@snokam/tenant": "2.1.2",
30
- "@snokam/react-query": "1.1.0",
31
28
  "@snokam/identity": "1.1.0",
29
+ "@snokam/platform-client": "3.1.1",
30
+ "@snokam/react-query": "1.1.0",
31
+ "@snokam/tenant": "2.1.3",
32
32
  "@snokam/ui": "2.0.3"
33
33
  },
34
34
  "peerDependencies": {
@@ -14,20 +14,22 @@ export interface ConsentClaims extends AuthorizationParams {
14
14
  clientName: string;
15
15
  businessName: string;
16
16
  email: string;
17
+ nonce: string;
17
18
  }
18
19
 
19
- const PURPOSE = "oauth-consent";
20
- const TTL_SECONDS = 600;
20
+ const PURPOSE = "app-sign-in-consent";
21
+ export const CONSENT_TTL_SECONDS = 600;
21
22
 
22
23
  export function sealConsent(claims: ConsentClaims, secret: string): string {
23
- return seal(claims, secret, TTL_SECONDS, PURPOSE);
24
+ return seal(claims, secret, CONSENT_TTL_SECONDS, PURPOSE);
24
25
  }
25
26
 
26
- export function readOAuthConsent(
27
+ export function readAppSignInConsent(
27
28
  ticket: string,
28
29
  secret: string
29
30
  ): ConsentClaims | null {
30
31
  return open<ConsentClaims>(ticket, secret, PURPOSE, {
31
- onReject: (reason) => console.warn(`[oauth] consent rejected: ${reason}`),
32
+ onReject: (reason) =>
33
+ console.warn(`[app-sign-in] consent rejected: ${reason}`),
32
34
  });
33
35
  }
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "crypto";
1
2
  import { getToken } from "next-auth/jwt";
2
3
  import {
3
4
  getAuthUrl,
@@ -8,12 +9,14 @@ import { resolveAccessToken } from "../route-handler/token";
8
9
  import type { Auth } from "../types";
9
10
  import {
10
11
  type AuthorizationParams,
11
- readOAuthConsent,
12
+ CONSENT_TTL_SECONDS,
13
+ readAppSignInConsent,
12
14
  sealConsent,
13
15
  } from "./consent";
14
16
  import { approveAuthorization, previewAuthorization } from "./platform";
15
17
 
16
18
  const ACCESS_DENIED = "access_denied";
19
+ const NONCE_COOKIE = "app-sign-in";
17
20
 
18
21
  function invalidRequest(): Response {
19
22
  return new Response("Invalid authorization request", { status: 400 });
@@ -52,7 +55,32 @@ function loginRedirect(request: Request, resource: string, prompt?: string) {
52
55
  return Response.redirect(login.toString(), 302);
53
56
  }
54
57
 
58
+ function nonceCookie(request: Request, value: string, maxAge: number): string {
59
+ const secure = requestOrigin(request).startsWith("https://");
60
+ return [
61
+ `${NONCE_COOKIE}=${value}`,
62
+ "Path=/oauth",
63
+ `Max-Age=${maxAge}`,
64
+ "HttpOnly",
65
+ "SameSite=Lax",
66
+ secure ? "Secure" : "",
67
+ ]
68
+ .filter(Boolean)
69
+ .join("; ");
70
+ }
71
+
72
+ function nonceFrom(request: Request): string | null {
73
+ const cookies = request.headers.get("cookie") ?? "";
74
+ const match = cookies.match(new RegExp(`(?:^|;\\s*)${NONCE_COOKIE}=([^;]+)`));
75
+ return match ? match[1] : null;
76
+ }
77
+
78
+ function sameOrigin(request: Request): boolean {
79
+ return request.headers.get("origin") === requestOrigin(request);
80
+ }
81
+
55
82
  function clientRedirect(
83
+ request: Request,
56
84
  redirectUri: string,
57
85
  state: string,
58
86
  result: Record<string, string>
@@ -64,11 +92,14 @@ function clientRedirect(
64
92
  if (state) target.searchParams.set("state", state);
65
93
  return new Response(null, {
66
94
  status: 303,
67
- headers: { Location: target.toString() },
95
+ headers: {
96
+ Location: target.toString(),
97
+ "Set-Cookie": nonceCookie(request, "", 0),
98
+ },
68
99
  });
69
100
  }
70
101
 
71
- export function createOAuthAuthorizeHandler(authConfig: Auth.Config) {
102
+ export function createAppSignInAuthorizeHandler(authConfig: Auth.Config) {
72
103
  return async function GET(request: Request) {
73
104
  const query = new URL(request.url).searchParams;
74
105
  const params = readParams(query);
@@ -87,6 +118,7 @@ export function createOAuthAuthorizeHandler(authConfig: Auth.Config) {
87
118
  : invalidRequest();
88
119
  }
89
120
 
121
+ const nonce = randomUUID();
90
122
  const ticket = sealConsent(
91
123
  {
92
124
  ...params,
@@ -94,29 +126,40 @@ export function createOAuthAuthorizeHandler(authConfig: Auth.Config) {
94
126
  business: new URL(params.resource).hostname,
95
127
  state: query.get("state") ?? "",
96
128
  email,
129
+ nonce,
97
130
  },
98
131
  authConfig.nextAuthSecret
99
132
  );
100
133
  const consent = new URL("/oauth/consent", requestOrigin(request));
101
134
  consent.searchParams.set("ticket", ticket);
102
- return Response.redirect(consent.toString(), 302);
135
+ return new Response(null, {
136
+ status: 302,
137
+ headers: {
138
+ Location: consent.toString(),
139
+ "Set-Cookie": nonceCookie(request, nonce, CONSENT_TTL_SECONDS),
140
+ },
141
+ });
103
142
  };
104
143
  }
105
144
 
106
- export function createOAuthApproveHandler(authConfig: Auth.Config) {
145
+ export function createAppSignInApproveHandler(authConfig: Auth.Config) {
107
146
  return async function POST(request: Request) {
147
+ if (!sameOrigin(request)) return invalidRequest();
148
+
108
149
  const form = await request.formData();
109
- const consent = readOAuthConsent(
150
+ const consent = readAppSignInConsent(
110
151
  String(form.get("ticket") ?? ""),
111
152
  authConfig.nextAuthSecret
112
153
  );
113
- if (!consent) return invalidRequest();
154
+ if (!consent || consent.nonce !== nonceFrom(request)) {
155
+ return invalidRequest();
156
+ }
114
157
 
115
158
  const email = await signedInEmail(request, authConfig.nextAuthSecret);
116
159
  if (email !== consent.email) return invalidRequest();
117
160
 
118
161
  if (form.get("decision") !== "approve") {
119
- return clientRedirect(consent.redirectUri, consent.state, {
162
+ return clientRedirect(request, consent.redirectUri, consent.state, {
120
163
  error: ACCESS_DENIED,
121
164
  });
122
165
  }
@@ -125,12 +168,13 @@ export function createOAuthApproveHandler(authConfig: Auth.Config) {
125
168
  if ("error" in access) return invalidRequest();
126
169
 
127
170
  const approved = await approveAuthorization(consent, access.accessToken);
128
- return "error" in approved
129
- ? clientRedirect(consent.redirectUri, consent.state, {
130
- error: approved.error,
131
- })
132
- : clientRedirect(consent.redirectUri, consent.state, {
133
- code: approved.value.code,
134
- });
171
+ return clientRedirect(
172
+ request,
173
+ consent.redirectUri,
174
+ consent.state,
175
+ "error" in approved
176
+ ? { error: approved.error }
177
+ : { code: approved.value.code }
178
+ );
135
179
  };
136
180
  }
@@ -1,3 +1,4 @@
1
+ import { getManagedBearer } from "@snokam/identity";
1
2
  import type { AuthorizationParams } from "./consent";
2
3
 
3
4
  export interface AuthorizationPreview {
@@ -7,6 +8,8 @@ export interface AuthorizationPreview {
7
8
 
8
9
  type Outcome<T> = { value: T } | { error: string };
9
10
 
11
+ const UNAVAILABLE = "temporarily_unavailable";
12
+
10
13
  function apiBase(): string {
11
14
  return (process.env.NEXT_PUBLIC_PLATFORM_API_BASE_URL ?? "").replace(
12
15
  /\/+$/,
@@ -17,15 +20,15 @@ function apiBase(): string {
17
20
  async function post<T>(
18
21
  path: string,
19
22
  params: AuthorizationParams,
20
- accessToken: string
23
+ userToken: string
21
24
  ): Promise<Outcome<T>> {
25
+ const loginSite = await getManagedBearer();
26
+ if (!loginSite) return { error: UNAVAILABLE };
27
+
22
28
  const response = await fetch(`${apiBase()}${path}`, {
23
29
  method: "POST",
24
- headers: {
25
- "Content-Type": "application/json",
26
- Authorization: `Bearer ${accessToken}`,
27
- },
28
- body: JSON.stringify(params),
30
+ headers: { "Content-Type": "application/json", Authorization: loginSite },
31
+ body: JSON.stringify({ ...params, userToken }),
29
32
  cache: "no-store",
30
33
  });
31
34
  const body = await response.json().catch(() => ({}));
@@ -36,18 +39,14 @@ async function post<T>(
36
39
 
37
40
  export function previewAuthorization(
38
41
  params: AuthorizationParams,
39
- accessToken: string
42
+ userToken: string
40
43
  ): Promise<Outcome<AuthorizationPreview>> {
41
- return post(
42
- "/v1.0/protected/oauth/authorization/preview",
43
- params,
44
- accessToken
45
- );
44
+ return post("/v1.0/protected/oauth/authorization/preview", params, userToken);
46
45
  }
47
46
 
48
47
  export function approveAuthorization(
49
48
  params: AuthorizationParams,
50
- accessToken: string
49
+ userToken: string
51
50
  ): Promise<Outcome<{ code: string }>> {
52
- return post("/v1.0/protected/oauth/authorization", params, accessToken);
51
+ return post("/v1.0/protected/oauth/authorization", params, userToken);
53
52
  }
@@ -50,8 +50,8 @@ export {
50
50
  createHandoffLogoutHandler,
51
51
  } from "../handoff/handlers";
52
52
  export {
53
- createOAuthAuthorizeHandler,
54
- createOAuthApproveHandler,
55
- } from "../oauth/handlers";
56
- export { readOAuthConsent } from "../oauth/consent";
53
+ createAppSignInAuthorizeHandler,
54
+ createAppSignInApproveHandler,
55
+ } from "../app-sign-in/handlers";
56
+ export { readAppSignInConsent } from "../app-sign-in/consent";
57
57
  export type { Auth } from "../types";