@snokam/auth 3.2.0 → 3.2.2
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,16 @@
|
|
|
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.2](https://github.com/snokam/components/compare/@snokam/auth@3.2.1...@snokam/auth@3.2.2) (2026-10-02)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @snokam/auth
|
|
9
|
+
|
|
10
|
+
## [3.2.1](https://github.com/snokam/components/compare/@snokam/auth@3.2.0...@snokam/auth@3.2.1) (2026-10-02)
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
- **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))
|
|
15
|
+
|
|
6
16
|
# [3.2.0](https://github.com/snokam/components/compare/@snokam/auth@3.1.1...@snokam/auth@3.2.0) (2026-10-02)
|
|
7
17
|
|
|
8
18
|
### 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.
|
|
4
|
+
"version": "3.2.2",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
7
7
|
"files": [
|
|
@@ -25,11 +25,11 @@
|
|
|
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.
|
|
29
|
-
"@snokam/tenant": "2.1.2",
|
|
28
|
+
"@snokam/platform-client": "3.2.0",
|
|
30
29
|
"@snokam/react-query": "1.1.0",
|
|
31
|
-
"@snokam/
|
|
32
|
-
"@snokam/
|
|
30
|
+
"@snokam/ui": "2.0.3",
|
|
31
|
+
"@snokam/tenant": "2.1.4",
|
|
32
|
+
"@snokam/identity": "1.1.0"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"next": "16.3.3",
|
|
@@ -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 = "
|
|
20
|
-
const
|
|
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,
|
|
24
|
+
return seal(claims, secret, CONSENT_TTL_SECONDS, PURPOSE);
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export function
|
|
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) =>
|
|
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
|
-
|
|
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: {
|
|
95
|
+
headers: {
|
|
96
|
+
Location: target.toString(),
|
|
97
|
+
"Set-Cookie": nonceCookie(request, "", 0),
|
|
98
|
+
},
|
|
68
99
|
});
|
|
69
100
|
}
|
|
70
101
|
|
|
71
|
-
export function
|
|
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
|
|
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
|
|
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 =
|
|
150
|
+
const consent = readAppSignInConsent(
|
|
110
151
|
String(form.get("ticket") ?? ""),
|
|
111
152
|
authConfig.nextAuthSecret
|
|
112
153
|
);
|
|
113
|
-
if (!consent
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
+
userToken: string
|
|
51
50
|
): Promise<Outcome<{ code: string }>> {
|
|
52
|
-
return post("/v1.0/protected/oauth/authorization", params,
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
} from "../
|
|
56
|
-
export {
|
|
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";
|