@o3co/create-auth-provider 0.4.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/README.ja.md +107 -0
- package/README.md +107 -0
- package/dist/__tests__/index.test.d.mts +2 -0
- package/dist/__tests__/index.test.d.mts.map +1 -0
- package/dist/__tests__/index.test.mjs +226 -0
- package/dist/cli.d.mts +3 -0
- package/dist/cli.d.mts.map +1 -0
- package/dist/cli.mjs +18 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +166 -0
- package/package.json +28 -0
- package/templates/standalone/.dockerignore +9 -0
- package/templates/standalone/.env.example +21 -0
- package/templates/standalone/Dockerfile +58 -0
- package/templates/standalone/Makefile +12 -0
- package/templates/standalone/README.ja.md +205 -0
- package/templates/standalone/README.md +205 -0
- package/templates/standalone/config/application.conf +132 -0
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +14 -0
- package/templates/standalone/config/development.conf +8 -0
- package/templates/standalone/config/production.conf +10 -0
- package/templates/standalone/docker-compose.test.yml +16 -0
- package/templates/standalone/docker-compose.yml +26 -0
- package/templates/standalone/package.json +45 -0
- package/templates/standalone/src/__tests__/configPath.test.mts +50 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +204 -0
- package/templates/standalone/src/app.mts +201 -0
- package/templates/standalone/src/configPath.mts +37 -0
- package/templates/standalone/src/logger.mts +18 -0
- package/templates/standalone/tests/Makefile +5 -0
- package/templates/standalone/tests/did-grant.test.js +193 -0
- package/templates/standalone/tests/index.test.js +218 -0
- package/templates/standalone/tests/package-lock.json +2225 -0
- package/templates/standalone/tests/package.json +17 -0
- package/templates/standalone/tsconfig.json +12 -0
- package/templates/standalone/vitest.config.mts +7 -0
- package/templates/versions.json +9 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import crypto from "node:crypto";
|
|
18
|
+
import * as ed from "@noble/ed25519";
|
|
19
|
+
import axios from "axios";
|
|
20
|
+
import { describe, expect, it } from "vitest";
|
|
21
|
+
|
|
22
|
+
const BASE_URL = process.env.API_BASE_URL || "http://localhost:3000";
|
|
23
|
+
|
|
24
|
+
const client = axios.create({
|
|
25
|
+
baseURL: BASE_URL,
|
|
26
|
+
validateStatus: () => true,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Helper: generate a valid DID auth request body
|
|
30
|
+
const createDidAuthRequest = async (overrides = {}) => {
|
|
31
|
+
const privateKey = ed.utils.randomPrivateKey();
|
|
32
|
+
const publicKey = await ed.getPublicKeyAsync(privateKey);
|
|
33
|
+
|
|
34
|
+
const did = "did:example:registry.example.com:org:test";
|
|
35
|
+
const nonce = crypto.randomUUID();
|
|
36
|
+
const timestamp = new Date().toISOString();
|
|
37
|
+
const audience = "https://api.example.com";
|
|
38
|
+
|
|
39
|
+
const message = JSON.stringify({ did, timestamp, nonce, audience });
|
|
40
|
+
const messageBytes = new TextEncoder().encode(message);
|
|
41
|
+
const signature = await ed.signAsync(messageBytes, privateKey);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
grant_type: "did",
|
|
45
|
+
did,
|
|
46
|
+
signature: Buffer.from(signature).toString("base64"),
|
|
47
|
+
message,
|
|
48
|
+
publicKey: Buffer.from(publicKey).toString("base64"),
|
|
49
|
+
// Expose internals for test manipulation
|
|
50
|
+
_privateKey: privateKey,
|
|
51
|
+
_publicKey: publicKey,
|
|
52
|
+
_nonce: nonce,
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe("POST /oauth/token grant_type=did", () => {
|
|
58
|
+
it("returns a JWT access token for a valid DID authentication", async () => {
|
|
59
|
+
const body = await createDidAuthRequest();
|
|
60
|
+
const res = await client.post("/oauth/token", body);
|
|
61
|
+
|
|
62
|
+
expect(res.status).toBe(200);
|
|
63
|
+
expect(res.data.access_token).toBeDefined();
|
|
64
|
+
expect(res.data.token_type).toBe("Bearer");
|
|
65
|
+
// M2M: no refresh token
|
|
66
|
+
expect(res.data.refresh_token).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns 401 for an invalid signature", async () => {
|
|
70
|
+
const body = await createDidAuthRequest();
|
|
71
|
+
// Corrupt the signature
|
|
72
|
+
body.signature = Buffer.from(
|
|
73
|
+
"invalid-signature-data-that-is-long-enough-to-be-64-bytes-padding!!",
|
|
74
|
+
).toString("base64");
|
|
75
|
+
|
|
76
|
+
const res = await client.post("/oauth/token", body);
|
|
77
|
+
expect(res.status).toBe(401);
|
|
78
|
+
expect(res.data.error).toBe("invalid_grant");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns 400 for an expired timestamp", async () => {
|
|
82
|
+
const privateKey = ed.utils.randomPrivateKey();
|
|
83
|
+
const publicKey = await ed.getPublicKeyAsync(privateKey);
|
|
84
|
+
|
|
85
|
+
const did = "did:example:registry.example.com:org:test";
|
|
86
|
+
// Timestamp 10 minutes ago (exceeds 5 minute max age)
|
|
87
|
+
const timestamp = new Date(Date.now() - 10 * 60 * 1000).toISOString();
|
|
88
|
+
const nonce = crypto.randomUUID();
|
|
89
|
+
|
|
90
|
+
const message = JSON.stringify({ did, timestamp, nonce });
|
|
91
|
+
const messageBytes = new TextEncoder().encode(message);
|
|
92
|
+
const signature = await ed.signAsync(messageBytes, privateKey);
|
|
93
|
+
|
|
94
|
+
const res = await client.post("/oauth/token", {
|
|
95
|
+
grant_type: "did",
|
|
96
|
+
did,
|
|
97
|
+
signature: Buffer.from(signature).toString("base64"),
|
|
98
|
+
message,
|
|
99
|
+
publicKey: Buffer.from(publicKey).toString("base64"),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(res.status).toBe(400);
|
|
103
|
+
expect(res.data.error).toBe("invalid_request");
|
|
104
|
+
expect(res.data.error_description).toContain("timestamp");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns 400 on nonce replay (same nonce used twice)", async () => {
|
|
108
|
+
const body = await createDidAuthRequest();
|
|
109
|
+
|
|
110
|
+
// First request should succeed
|
|
111
|
+
const res1 = await client.post("/oauth/token", body);
|
|
112
|
+
expect(res1.status).toBe(200);
|
|
113
|
+
|
|
114
|
+
// Second request with the same body (same nonce) should fail
|
|
115
|
+
const res2 = await client.post("/oauth/token", body);
|
|
116
|
+
expect(res2.status).toBe(400);
|
|
117
|
+
expect(res2.data.error).toBe("invalid_request");
|
|
118
|
+
expect(res2.data.error_description).toContain("nonce");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns 400 when required fields are missing", async () => {
|
|
122
|
+
// Missing did
|
|
123
|
+
const res1 = await client.post("/oauth/token", {
|
|
124
|
+
grant_type: "did",
|
|
125
|
+
signature: "abc",
|
|
126
|
+
message: "{}",
|
|
127
|
+
});
|
|
128
|
+
expect(res1.status).toBe(400);
|
|
129
|
+
expect(res1.data.error).toBe("invalid_request");
|
|
130
|
+
|
|
131
|
+
// Missing signature
|
|
132
|
+
const res2 = await client.post("/oauth/token", {
|
|
133
|
+
grant_type: "did",
|
|
134
|
+
did: "did:example:test",
|
|
135
|
+
message: "{}",
|
|
136
|
+
});
|
|
137
|
+
expect(res2.status).toBe(400);
|
|
138
|
+
|
|
139
|
+
// Missing message
|
|
140
|
+
const res3 = await client.post("/oauth/token", {
|
|
141
|
+
grant_type: "did",
|
|
142
|
+
did: "did:example:test",
|
|
143
|
+
signature: "abc",
|
|
144
|
+
});
|
|
145
|
+
expect(res3.status).toBe(400);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns 400 when message is not valid JSON", async () => {
|
|
149
|
+
const res = await client.post("/oauth/token", {
|
|
150
|
+
grant_type: "did",
|
|
151
|
+
did: "did:example:test",
|
|
152
|
+
signature: "abc",
|
|
153
|
+
message: "not-json",
|
|
154
|
+
publicKey: "abc",
|
|
155
|
+
});
|
|
156
|
+
expect(res.status).toBe(400);
|
|
157
|
+
expect(res.data.error_description).toContain("valid JSON");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns 400 when message.did does not match top-level did", async () => {
|
|
161
|
+
const res = await client.post("/oauth/token", {
|
|
162
|
+
grant_type: "did",
|
|
163
|
+
did: "did:example:one",
|
|
164
|
+
signature: "abc",
|
|
165
|
+
message: JSON.stringify({
|
|
166
|
+
did: "did:example:other",
|
|
167
|
+
timestamp: new Date().toISOString(),
|
|
168
|
+
nonce: crypto.randomUUID(),
|
|
169
|
+
}),
|
|
170
|
+
publicKey: "abc",
|
|
171
|
+
});
|
|
172
|
+
expect(res.status).toBe(400);
|
|
173
|
+
expect(res.data.error_description).toContain("must match");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("returns 400 when publicKey is missing", async () => {
|
|
177
|
+
const did = "did:example:test";
|
|
178
|
+
const message = JSON.stringify({
|
|
179
|
+
did,
|
|
180
|
+
timestamp: new Date().toISOString(),
|
|
181
|
+
nonce: crypto.randomUUID(),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const res = await client.post("/oauth/token", {
|
|
185
|
+
grant_type: "did",
|
|
186
|
+
did,
|
|
187
|
+
signature: "abc",
|
|
188
|
+
message,
|
|
189
|
+
});
|
|
190
|
+
expect(res.status).toBe(400);
|
|
191
|
+
expect(res.data.error_description).toContain("publicKey");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import axios from "axios";
|
|
18
|
+
import jwt from "jsonwebtoken";
|
|
19
|
+
import { describe, expect, it } from "vitest";
|
|
20
|
+
|
|
21
|
+
const BASE_URL = process.env.API_BASE_URL || "http://localhost:3000";
|
|
22
|
+
const JWT_SECRET = process.env.OAUTH_JWT_SECRET || "your-token-secret";
|
|
23
|
+
|
|
24
|
+
const client = axios.create({
|
|
25
|
+
baseURL: BASE_URL,
|
|
26
|
+
withCredentials: true,
|
|
27
|
+
validateStatus: () => true,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const _extractCookies = (res) => (res.headers["set-cookie"] ?? []).join("; ");
|
|
31
|
+
|
|
32
|
+
describe("POST /oauth/introspect", () => {
|
|
33
|
+
// Helper: self-introspect sends the token as both Bearer credential and body parameter
|
|
34
|
+
const selfIntrospect = (token, contentType = "application/json") => {
|
|
35
|
+
const headers = {
|
|
36
|
+
Authorization: `Bearer ${token}`,
|
|
37
|
+
"Content-Type": contentType,
|
|
38
|
+
};
|
|
39
|
+
const data =
|
|
40
|
+
contentType === "application/x-www-form-urlencoded"
|
|
41
|
+
? `token=${encodeURIComponent(token)}`
|
|
42
|
+
: { token };
|
|
43
|
+
return client.post("/oauth/introspect", data, { headers });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
it("returns 401 when no authentication is provided", async () => {
|
|
47
|
+
const res = await client.post("/oauth/introspect", {});
|
|
48
|
+
expect(res.status).toBe(401);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("self-introspect (JSON)", () => {
|
|
52
|
+
it("returns active: true for a valid JWT", async () => {
|
|
53
|
+
const token = jwt.sign({ user: { id: 1 }, scopes: ["read"] }, JWT_SECRET, {
|
|
54
|
+
expiresIn: 60,
|
|
55
|
+
});
|
|
56
|
+
const res = await selfIntrospect(token);
|
|
57
|
+
expect(res.status).toBe(200);
|
|
58
|
+
expect(res.data.active).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns scope as space-separated string per RFC 7662", async () => {
|
|
62
|
+
const token = jwt.sign({ user: { id: 1 }, scopes: ["read", "write"] }, JWT_SECRET, {
|
|
63
|
+
expiresIn: 60,
|
|
64
|
+
});
|
|
65
|
+
const res = await selfIntrospect(token);
|
|
66
|
+
expect(res.status).toBe(200);
|
|
67
|
+
expect(res.data.scope).toBe("read write");
|
|
68
|
+
expect(res.data.scopes).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns 401 for an invalid JWT", async () => {
|
|
72
|
+
const res = await selfIntrospect("invalid.token.here");
|
|
73
|
+
expect(res.status).toBe(401);
|
|
74
|
+
expect(res.data.active).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns 401 for an expired JWT", async () => {
|
|
78
|
+
const token = jwt.sign({ user: { id: 1 } }, JWT_SECRET, { expiresIn: -1 });
|
|
79
|
+
const res = await selfIntrospect(token);
|
|
80
|
+
expect(res.status).toBe(401);
|
|
81
|
+
expect(res.data.active).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("self-introspect (application/x-www-form-urlencoded)", () => {
|
|
86
|
+
it("returns active: true for a valid JWT", async () => {
|
|
87
|
+
const token = jwt.sign({ user: { id: 1 }, scopes: ["read"] }, JWT_SECRET, {
|
|
88
|
+
expiresIn: 60,
|
|
89
|
+
});
|
|
90
|
+
const res = await selfIntrospect(token, "application/x-www-form-urlencoded");
|
|
91
|
+
expect(res.status).toBe(200);
|
|
92
|
+
expect(res.data.active).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns 401 for an invalid JWT", async () => {
|
|
96
|
+
const res = await selfIntrospect("invalid.token.here", "application/x-www-form-urlencoded");
|
|
97
|
+
expect(res.status).toBe(401);
|
|
98
|
+
expect(res.data.active).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns 401 for an expired JWT", async () => {
|
|
102
|
+
const token = jwt.sign({ user: { id: 1 } }, JWT_SECRET, { expiresIn: -1 });
|
|
103
|
+
const res = await selfIntrospect(token, "application/x-www-form-urlencoded");
|
|
104
|
+
expect(res.status).toBe(401);
|
|
105
|
+
expect(res.data.active).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("POST /session/login", () => {
|
|
111
|
+
it("returns 400 when no credentials are provided", async () => {
|
|
112
|
+
const res = await client.post("/session/login", {});
|
|
113
|
+
expect(res.status).toBe(400);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns 400 when redirect_to has an invalid scheme", async () => {
|
|
117
|
+
const res = await client.post("/session/login", {
|
|
118
|
+
username: "testuser",
|
|
119
|
+
password: "testpass",
|
|
120
|
+
redirect_to: "javascript:alert(1)",
|
|
121
|
+
});
|
|
122
|
+
expect(res.status).toBe(400);
|
|
123
|
+
expect(res.data.error).toBe("invalid_redirect");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns 400 when redirect_to is not a valid URL", async () => {
|
|
127
|
+
const res = await client.post("/session/login", {
|
|
128
|
+
username: "testuser",
|
|
129
|
+
password: "testpass",
|
|
130
|
+
redirect_to: "not-a-url",
|
|
131
|
+
});
|
|
132
|
+
expect(res.status).toBe(400);
|
|
133
|
+
expect(res.data.error).toBe("invalid_redirect");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("returns 400 when redirect_to exceeds 2048 characters", async () => {
|
|
137
|
+
const res = await client.post("/session/login", {
|
|
138
|
+
username: "testuser",
|
|
139
|
+
password: "testpass",
|
|
140
|
+
redirect_to: `https://example.com/${"a".repeat(2048)}`,
|
|
141
|
+
});
|
|
142
|
+
expect(res.status).toBe(400);
|
|
143
|
+
expect(res.data.error).toBe("invalid_redirect");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("accepts a valid redirect_to without error", async () => {
|
|
147
|
+
// redirect_to validation runs before authentication, so even with
|
|
148
|
+
// invalid credentials, a valid redirect_to should not cause a 400
|
|
149
|
+
// with error: "invalid_redirect". It should fail with 401 (auth failure).
|
|
150
|
+
const res = await client.post("/session/login", {
|
|
151
|
+
username: "testuser",
|
|
152
|
+
password: "testpass",
|
|
153
|
+
redirect_to: "http://localhost:3000/oauth/authorize",
|
|
154
|
+
});
|
|
155
|
+
expect(res.status).not.toBe(400);
|
|
156
|
+
expect(res.data.error).not.toBe("invalid_redirect");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("POST /oauth/token", () => {
|
|
161
|
+
it("returns 401 for grant_type=session without an authenticated session", async () => {
|
|
162
|
+
const res = await client.post("/oauth/token", "grant_type=session", {
|
|
163
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
164
|
+
});
|
|
165
|
+
expect(res.status).toBe(401);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("returns 400 for an unsupported grant_type", async () => {
|
|
169
|
+
const res = await client.post("/oauth/token", "grant_type=unknown", {
|
|
170
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
171
|
+
});
|
|
172
|
+
expect(res.status).toBe(400);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("returns 400 for grant_type=authorization without a code", async () => {
|
|
176
|
+
const res = await client.post("/oauth/token", "grant_type=authorization", {
|
|
177
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
178
|
+
});
|
|
179
|
+
expect(res.status).toBe(400);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("GET /oauth/authorize", () => {
|
|
184
|
+
it("redirects to login with redirect_to when the session is not authenticated", async () => {
|
|
185
|
+
const res = await client.get(
|
|
186
|
+
"/oauth/authorize?response_type=code&client_id=test&redirect_uri=http://localhost:9999/callback",
|
|
187
|
+
{ maxRedirects: 0 },
|
|
188
|
+
);
|
|
189
|
+
expect(res.status).toBe(302);
|
|
190
|
+
const location = res.headers.location;
|
|
191
|
+
expect(location).toContain("login");
|
|
192
|
+
expect(location).toContain("redirect_to=");
|
|
193
|
+
expect(location).not.toContain("redirect_uri=");
|
|
194
|
+
// Verify the authorize URL is properly encoded (? and & should be escaped)
|
|
195
|
+
expect(location).toContain(encodeURIComponent("response_type=code"));
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("GET /session/oauth/federation/google", () => {
|
|
200
|
+
// NOTE: Development config has Google federation disabled (google.enabled: false).
|
|
201
|
+
// The googleEnabled check runs first and returns 404, so redirect_to validation
|
|
202
|
+
// tests cannot run in this environment. Validation logic is identical to
|
|
203
|
+
// POST /session/login and covered by those tests.
|
|
204
|
+
|
|
205
|
+
it("returns 404 when Google federation is disabled", async () => {
|
|
206
|
+
const res = await client.get(
|
|
207
|
+
"/session/oauth/federation/google?redirect_to=https://localhost:3001",
|
|
208
|
+
);
|
|
209
|
+
expect(res.status).toBe(404);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("returns 404 even with invalid redirect_to when Google is disabled", async () => {
|
|
213
|
+
const res = await client.get(
|
|
214
|
+
"/session/oauth/federation/google?redirect_to=javascript:alert(1)",
|
|
215
|
+
);
|
|
216
|
+
expect(res.status).toBe(404);
|
|
217
|
+
});
|
|
218
|
+
});
|