@effect-auth/core 0.1.0-alpha.4 → 0.1.0-alpha.6
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/AuditLog.d.ts +49 -1
- package/dist/AuditLog.d.ts.map +1 -1
- package/dist/AuditLog.js +28 -0
- package/dist/AuditLog.js.map +1 -1
- package/dist/Challenge.d.ts +1 -1
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js.map +1 -1
- package/dist/Client.d.ts +36 -1
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +59 -1
- package/dist/Client.js.map +1 -1
- package/dist/D1Kysely.d.ts +1 -1
- package/dist/D1Kysely.d.ts.map +1 -1
- package/dist/HttpApi/Api.d.ts +352 -3
- package/dist/HttpApi/Api.d.ts.map +1 -1
- package/dist/HttpApi/Api.js +317 -1
- package/dist/HttpApi/Api.js.map +1 -1
- package/dist/HttpApi/Endpoints.d.ts +132 -0
- package/dist/HttpApi/Endpoints.d.ts.map +1 -1
- package/dist/HttpApi/Endpoints.js +34 -1
- package/dist/HttpApi/Endpoints.js.map +1 -1
- package/dist/HttpApi/Schemas.d.ts +214 -0
- package/dist/HttpApi/Schemas.d.ts.map +1 -1
- package/dist/HttpApi/Schemas.js +125 -1
- package/dist/HttpApi/Schemas.js.map +1 -1
- package/dist/KyselyStorage.d.ts +18 -1
- package/dist/KyselyStorage.d.ts.map +1 -1
- package/dist/KyselyStorage.js +105 -2
- package/dist/KyselyStorage.js.map +1 -1
- package/dist/OAuth.d.ts +742 -0
- package/dist/OAuth.d.ts.map +1 -0
- package/dist/OAuth.js +1387 -0
- package/dist/OAuth.js.map +1 -0
- package/dist/StorageMigrations.d.ts +2 -1
- package/dist/StorageMigrations.d.ts.map +1 -1
- package/dist/StorageMigrations.js +20 -0
- package/dist/StorageMigrations.js.map +1 -1
- package/dist/Webhook.d.ts +72 -0
- package/dist/Webhook.d.ts.map +1 -0
- package/dist/Webhook.js +82 -0
- package/dist/Webhook.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/migrations/0011_auth_oauth_account.sql +16 -0
- package/package.json +9 -1
package/dist/OAuth.js
ADDED
|
@@ -0,0 +1,1387 @@
|
|
|
1
|
+
import { Brand, Context, Data, Duration, Effect, Encoding, Layer, Option, Redacted, } from "effect";
|
|
2
|
+
import { Challenge } from "./Challenge.js";
|
|
3
|
+
import { Crypto } from "./Crypto.js";
|
|
4
|
+
import { ChallengeId, Email, OAuthAccountId, OAuthProviderId, UnixMillis, UserId, } from "./Identifiers.js";
|
|
5
|
+
import { currentUnixMillis } from "./Internal.js";
|
|
6
|
+
import { JwtAlgorithms, JwtError, JwtKeyId, JwtKeys, JwtVerifier, } from "./Jwt.js";
|
|
7
|
+
export const OAuthStateToken = Brand.nominal();
|
|
8
|
+
export const OAuthCodeVerifier = Brand.nominal();
|
|
9
|
+
export const OAuthCodeChallenge = Brand.nominal();
|
|
10
|
+
export const OAuthNonce = Brand.nominal();
|
|
11
|
+
export const OAuthAuthorizationCode = Brand.nominal();
|
|
12
|
+
export const OAuthAccessToken = Brand.nominal();
|
|
13
|
+
export const OAuthRefreshToken = Brand.nominal();
|
|
14
|
+
export const OAuthIdToken = Brand.nominal();
|
|
15
|
+
export const defaultOAuthStateTtl = Duration.minutes(10);
|
|
16
|
+
export const defaultOAuthLinkConfirmationTtl = Duration.minutes(15);
|
|
17
|
+
export const defaultOAuthJwksCacheTtl = Duration.minutes(10);
|
|
18
|
+
export const defaultOAuthStateSecretBytes = 32;
|
|
19
|
+
export const defaultOAuthCodeVerifierBytes = 32;
|
|
20
|
+
export const defaultOAuthLinkConfirmationSecretBytes = 32;
|
|
21
|
+
export const defaultOAuthNonceBytes = 16;
|
|
22
|
+
export const appleSignInProviderId = OAuthProviderId("apple");
|
|
23
|
+
export const appleSignInIssuer = "https://appleid.apple.com";
|
|
24
|
+
export const appleSignInAuthorizationEndpoint = "https://appleid.apple.com/auth/authorize";
|
|
25
|
+
export const appleSignInTokenEndpoint = "https://appleid.apple.com/auth/token";
|
|
26
|
+
export const appleSignInRevocationEndpoint = "https://appleid.apple.com/auth/revoke";
|
|
27
|
+
export const appleSignInJwksUri = "https://appleid.apple.com/auth/keys";
|
|
28
|
+
export const appleSignInPrivateRelayDomain = "privaterelay.appleid.com";
|
|
29
|
+
export const appleSignInDefaultScopes = ["openid", "email", "name"];
|
|
30
|
+
export const appleSignInDefaultAuthorizationParams = {
|
|
31
|
+
response_mode: "form_post",
|
|
32
|
+
};
|
|
33
|
+
export const githubOAuthProviderId = OAuthProviderId("github");
|
|
34
|
+
export const githubOAuthAuthorizationEndpoint = "https://github.com/login/oauth/authorize";
|
|
35
|
+
export const githubOAuthTokenEndpoint = "https://github.com/login/oauth/access_token";
|
|
36
|
+
export const githubOAuthUserEndpoint = "https://api.github.com/user";
|
|
37
|
+
export const githubOAuthEmailsEndpoint = "https://api.github.com/user/emails";
|
|
38
|
+
export const githubOAuthDefaultScopes = ["read:user", "user:email"];
|
|
39
|
+
export const githubOAuthApiVersion = "2022-11-28";
|
|
40
|
+
export const githubOAuthApiVersionHeader = {
|
|
41
|
+
"x-github-api-version": githubOAuthApiVersion,
|
|
42
|
+
};
|
|
43
|
+
export const googleOidcProviderId = OAuthProviderId("google");
|
|
44
|
+
export const googleOidcIssuer = "https://accounts.google.com";
|
|
45
|
+
export const googleOidcAuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
46
|
+
export const googleOidcTokenEndpoint = "https://oauth2.googleapis.com/token";
|
|
47
|
+
export const googleOidcRevocationEndpoint = "https://oauth2.googleapis.com/revoke";
|
|
48
|
+
export const googleOidcUserInfoEndpoint = "https://openidconnect.googleapis.com/v1/userinfo";
|
|
49
|
+
export const googleOidcJwksUri = "https://www.googleapis.com/oauth2/v3/certs";
|
|
50
|
+
export const googleOidcDefaultScopes = ["openid", "email", "profile"];
|
|
51
|
+
export const microsoftOidcProviderId = OAuthProviderId("microsoft");
|
|
52
|
+
export const microsoftOidcLoginBaseUrl = "https://login.microsoftonline.com";
|
|
53
|
+
export const microsoftOidcUserInfoEndpoint = "https://graph.microsoft.com/oidc/userinfo";
|
|
54
|
+
export const microsoftOidcDefaultScopes = ["openid", "email", "profile"];
|
|
55
|
+
export const genericOidcDefaultScopes = ["openid", "email", "profile"];
|
|
56
|
+
const microsoftOidcTenantPathSegment = (tenant) => encodeURIComponent(tenant.trim());
|
|
57
|
+
export const microsoftOidcAuthorizationEndpoint = (tenant) => `${microsoftOidcLoginBaseUrl}/${microsoftOidcTenantPathSegment(tenant)}/oauth2/v2.0/authorize`;
|
|
58
|
+
export const microsoftOidcTokenEndpoint = (tenant) => `${microsoftOidcLoginBaseUrl}/${microsoftOidcTenantPathSegment(tenant)}/oauth2/v2.0/token`;
|
|
59
|
+
export const microsoftOidcJwksUri = (tenant) => `${microsoftOidcLoginBaseUrl}/${microsoftOidcTenantPathSegment(tenant)}/discovery/v2.0/keys`;
|
|
60
|
+
export const microsoftOidcIssuer = (tenant) => `${microsoftOidcLoginBaseUrl}/${microsoftOidcTenantPathSegment(tenant)}/v2.0`;
|
|
61
|
+
export class OAuthProviderError extends Data.TaggedError("OAuthProviderError") {
|
|
62
|
+
}
|
|
63
|
+
export class OAuthUserInfoError extends Data.TaggedError("OAuthUserInfoError") {
|
|
64
|
+
}
|
|
65
|
+
export class OidcIdTokenVerifierError extends Data.TaggedError("OidcIdTokenVerifierError") {
|
|
66
|
+
}
|
|
67
|
+
export class OidcIdTokenVerifier extends Context.Service()("auth/OidcIdTokenVerifier") {
|
|
68
|
+
}
|
|
69
|
+
(function (OidcIdTokenVerifier) {
|
|
70
|
+
OidcIdTokenVerifier.make = (service) => OidcIdTokenVerifier.of(service);
|
|
71
|
+
})(OidcIdTokenVerifier || (OidcIdTokenVerifier = {}));
|
|
72
|
+
export class OAuthProfileNormalizationError extends Data.TaggedError("OAuthProfileNormalizationError") {
|
|
73
|
+
}
|
|
74
|
+
export class OAuthProfileNormalizer extends Context.Service()("auth/OAuthProfileNormalizer") {
|
|
75
|
+
}
|
|
76
|
+
(function (OAuthProfileNormalizer) {
|
|
77
|
+
OAuthProfileNormalizer.make = (service) => OAuthProfileNormalizer.of(service);
|
|
78
|
+
})(OAuthProfileNormalizer || (OAuthProfileNormalizer = {}));
|
|
79
|
+
export class OAuthTokenExchangeError extends Data.TaggedError("OAuthTokenExchangeError") {
|
|
80
|
+
}
|
|
81
|
+
export class OAuthTokenExchange extends Context.Service()("auth/OAuthTokenExchange") {
|
|
82
|
+
}
|
|
83
|
+
(function (OAuthTokenExchange) {
|
|
84
|
+
OAuthTokenExchange.make = (service) => OAuthTokenExchange.of(service);
|
|
85
|
+
})(OAuthTokenExchange || (OAuthTokenExchange = {}));
|
|
86
|
+
export class OAuthProviders extends Context.Service()("auth/OAuthProviders") {
|
|
87
|
+
}
|
|
88
|
+
(function (OAuthProviders) {
|
|
89
|
+
OAuthProviders.make = (service) => OAuthProviders.of(service);
|
|
90
|
+
})(OAuthProviders || (OAuthProviders = {}));
|
|
91
|
+
export class OAuthStateError extends Data.TaggedError("OAuthStateError") {
|
|
92
|
+
}
|
|
93
|
+
export class OAuthState extends Context.Service()("auth/OAuthState") {
|
|
94
|
+
}
|
|
95
|
+
(function (OAuthState) {
|
|
96
|
+
OAuthState.make = (service) => OAuthState.of(service);
|
|
97
|
+
})(OAuthState || (OAuthState = {}));
|
|
98
|
+
export class OAuthAccountStoreError extends Data.TaggedError("OAuthAccountStoreError") {
|
|
99
|
+
}
|
|
100
|
+
export class OAuthAccountStore extends Context.Service()("auth/OAuthAccountStore") {
|
|
101
|
+
}
|
|
102
|
+
(function (OAuthAccountStore) {
|
|
103
|
+
OAuthAccountStore.make = (service) => OAuthAccountStore.of(service);
|
|
104
|
+
})(OAuthAccountStore || (OAuthAccountStore = {}));
|
|
105
|
+
export class OAuthAccountLinkingError extends Data.TaggedError("OAuthAccountLinkingError") {
|
|
106
|
+
}
|
|
107
|
+
export class OAuthAccountLinking extends Context.Service()("auth/OAuthAccountLinking") {
|
|
108
|
+
}
|
|
109
|
+
(function (OAuthAccountLinking) {
|
|
110
|
+
OAuthAccountLinking.make = (service) => OAuthAccountLinking.of(service);
|
|
111
|
+
})(OAuthAccountLinking || (OAuthAccountLinking = {}));
|
|
112
|
+
export class OAuthAccountUnlinkingError extends Data.TaggedError("OAuthAccountUnlinkingError") {
|
|
113
|
+
}
|
|
114
|
+
export class OAuthAccountUnlinking extends Context.Service()("auth/OAuthAccountUnlinking") {
|
|
115
|
+
}
|
|
116
|
+
(function (OAuthAccountUnlinking) {
|
|
117
|
+
OAuthAccountUnlinking.make = (service) => OAuthAccountUnlinking.of(service);
|
|
118
|
+
})(OAuthAccountUnlinking || (OAuthAccountUnlinking = {}));
|
|
119
|
+
export class OAuthLinkConfirmationError extends Data.TaggedError("OAuthLinkConfirmationError") {
|
|
120
|
+
}
|
|
121
|
+
export class OAuthLinkConfirmation extends Context.Service()("auth/OAuthLinkConfirmation") {
|
|
122
|
+
}
|
|
123
|
+
(function (OAuthLinkConfirmation) {
|
|
124
|
+
OAuthLinkConfirmation.make = (service) => OAuthLinkConfirmation.of(service);
|
|
125
|
+
})(OAuthLinkConfirmation || (OAuthLinkConfirmation = {}));
|
|
126
|
+
const oauthStateError = (operation, message, cause) => new OAuthStateError({ operation, message, cause });
|
|
127
|
+
const oauthTokenExchangeError = (operation, message, cause) => new OAuthTokenExchangeError({ operation, message, cause });
|
|
128
|
+
const oidcIdTokenVerifierError = (operation, message, cause) => new OidcIdTokenVerifierError({ operation, message, cause });
|
|
129
|
+
const oauthAccountLinkingError = (operation, message, cause) => new OAuthAccountLinkingError({ operation, message, cause });
|
|
130
|
+
const oauthAccountUnlinkingError = (operation, message, cause) => new OAuthAccountUnlinkingError({ operation, message, cause });
|
|
131
|
+
const oauthLinkConfirmationError = (operation, message, cause) => new OAuthLinkConfirmationError({ operation, message, cause });
|
|
132
|
+
const oauthProfileNormalizationError = (reason, message, cause) => new OAuthProfileNormalizationError({
|
|
133
|
+
operation: "normalize",
|
|
134
|
+
reason,
|
|
135
|
+
message,
|
|
136
|
+
cause,
|
|
137
|
+
});
|
|
138
|
+
const oauthJwksJwtError = (message, cause) => new JwtError({
|
|
139
|
+
operation: "keys",
|
|
140
|
+
message,
|
|
141
|
+
...(cause === undefined ? {} : { cause }),
|
|
142
|
+
});
|
|
143
|
+
const oauthUserInfoError = (operation, message, cause) => new OAuthUserInfoError({
|
|
144
|
+
operation,
|
|
145
|
+
message,
|
|
146
|
+
...(cause === undefined ? {} : { cause }),
|
|
147
|
+
});
|
|
148
|
+
const textEncoder = new TextEncoder();
|
|
149
|
+
const oauthJwksPrivateMembers = new Set([
|
|
150
|
+
"d",
|
|
151
|
+
"p",
|
|
152
|
+
"q",
|
|
153
|
+
"dp",
|
|
154
|
+
"dq",
|
|
155
|
+
"qi",
|
|
156
|
+
"oth",
|
|
157
|
+
"k",
|
|
158
|
+
]);
|
|
159
|
+
const validateByteLength = (value, operation, label) => Number.isInteger(value) && value >= 16 && value <= 128
|
|
160
|
+
? Effect.succeed(value)
|
|
161
|
+
: Effect.fail(oauthStateError(operation, `${label} byte length must be an integer between 16 and 128`));
|
|
162
|
+
const validateOAuthLinkConfirmationSecretBytes = (value) => Number.isInteger(value) && value >= 16 && value <= 128
|
|
163
|
+
? Effect.succeed(value)
|
|
164
|
+
: Effect.fail(oauthLinkConfirmationError("start", "OAuth link confirmation secret byte length must be an integer between 16 and 128"));
|
|
165
|
+
const metadataValue = (value) => value === undefined ? undefined : structuredClone(value);
|
|
166
|
+
export const makeAppleSignInProvider = (input) => ({
|
|
167
|
+
id: input.id ?? appleSignInProviderId,
|
|
168
|
+
clientId: input.clientId,
|
|
169
|
+
authorizationEndpoint: appleSignInAuthorizationEndpoint,
|
|
170
|
+
tokenEndpoint: appleSignInTokenEndpoint,
|
|
171
|
+
revocationEndpoint: appleSignInRevocationEndpoint,
|
|
172
|
+
jwksUri: appleSignInJwksUri,
|
|
173
|
+
issuer: appleSignInIssuer,
|
|
174
|
+
redirectUri: input.redirectUri,
|
|
175
|
+
scopes: [...(input.scopes ?? appleSignInDefaultScopes)],
|
|
176
|
+
authorizationParams: {
|
|
177
|
+
...appleSignInDefaultAuthorizationParams,
|
|
178
|
+
...input.authorizationParams,
|
|
179
|
+
},
|
|
180
|
+
...(input.metadata === undefined ? {} : { metadata: metadataValue(input.metadata) }),
|
|
181
|
+
});
|
|
182
|
+
export const makeGithubOAuthProvider = (input) => ({
|
|
183
|
+
id: input.id ?? githubOAuthProviderId,
|
|
184
|
+
clientId: input.clientId,
|
|
185
|
+
authorizationEndpoint: githubOAuthAuthorizationEndpoint,
|
|
186
|
+
tokenEndpoint: githubOAuthTokenEndpoint,
|
|
187
|
+
userInfoEndpoint: githubOAuthUserEndpoint,
|
|
188
|
+
redirectUri: input.redirectUri,
|
|
189
|
+
scopes: [...(input.scopes ?? githubOAuthDefaultScopes)],
|
|
190
|
+
...(input.authorizationParams === undefined
|
|
191
|
+
? {}
|
|
192
|
+
: { authorizationParams: { ...input.authorizationParams } }),
|
|
193
|
+
...(input.metadata === undefined ? {} : { metadata: metadataValue(input.metadata) }),
|
|
194
|
+
});
|
|
195
|
+
export const makeGoogleOidcProvider = (input) => ({
|
|
196
|
+
id: input.id ?? googleOidcProviderId,
|
|
197
|
+
clientId: input.clientId,
|
|
198
|
+
authorizationEndpoint: googleOidcAuthorizationEndpoint,
|
|
199
|
+
tokenEndpoint: googleOidcTokenEndpoint,
|
|
200
|
+
revocationEndpoint: googleOidcRevocationEndpoint,
|
|
201
|
+
userInfoEndpoint: googleOidcUserInfoEndpoint,
|
|
202
|
+
jwksUri: googleOidcJwksUri,
|
|
203
|
+
issuer: googleOidcIssuer,
|
|
204
|
+
redirectUri: input.redirectUri,
|
|
205
|
+
scopes: [...(input.scopes ?? googleOidcDefaultScopes)],
|
|
206
|
+
...(input.authorizationParams === undefined
|
|
207
|
+
? {}
|
|
208
|
+
: { authorizationParams: { ...input.authorizationParams } }),
|
|
209
|
+
...(input.metadata === undefined ? {} : { metadata: metadataValue(input.metadata) }),
|
|
210
|
+
});
|
|
211
|
+
export const makeMicrosoftOidcProvider = (input) => ({
|
|
212
|
+
id: input.id ?? microsoftOidcProviderId,
|
|
213
|
+
clientId: input.clientId,
|
|
214
|
+
authorizationEndpoint: microsoftOidcAuthorizationEndpoint(input.tenant),
|
|
215
|
+
tokenEndpoint: microsoftOidcTokenEndpoint(input.tenant),
|
|
216
|
+
userInfoEndpoint: microsoftOidcUserInfoEndpoint,
|
|
217
|
+
jwksUri: microsoftOidcJwksUri(input.tenant),
|
|
218
|
+
issuer: microsoftOidcIssuer(input.tenant),
|
|
219
|
+
redirectUri: input.redirectUri,
|
|
220
|
+
scopes: [...(input.scopes ?? microsoftOidcDefaultScopes)],
|
|
221
|
+
...(input.authorizationParams === undefined
|
|
222
|
+
? {}
|
|
223
|
+
: { authorizationParams: { ...input.authorizationParams } }),
|
|
224
|
+
...(input.metadata === undefined ? {} : { metadata: metadataValue(input.metadata) }),
|
|
225
|
+
});
|
|
226
|
+
export const makeGenericOidcProvider = (input) => ({
|
|
227
|
+
id: input.id,
|
|
228
|
+
clientId: input.clientId,
|
|
229
|
+
authorizationEndpoint: input.authorizationEndpoint,
|
|
230
|
+
tokenEndpoint: input.tokenEndpoint,
|
|
231
|
+
issuer: input.issuer,
|
|
232
|
+
redirectUri: input.redirectUri,
|
|
233
|
+
scopes: [...(input.scopes ?? genericOidcDefaultScopes)],
|
|
234
|
+
...(input.userInfoEndpoint === undefined
|
|
235
|
+
? {}
|
|
236
|
+
: { userInfoEndpoint: input.userInfoEndpoint }),
|
|
237
|
+
...(input.jwksUri === undefined ? {} : { jwksUri: input.jwksUri }),
|
|
238
|
+
...(input.revocationEndpoint === undefined
|
|
239
|
+
? {}
|
|
240
|
+
: { revocationEndpoint: input.revocationEndpoint }),
|
|
241
|
+
...(input.authorizationParams === undefined
|
|
242
|
+
? {}
|
|
243
|
+
: { authorizationParams: { ...input.authorizationParams } }),
|
|
244
|
+
...(input.metadata === undefined ? {} : { metadata: metadataValue(input.metadata) }),
|
|
245
|
+
});
|
|
246
|
+
const cloneProviderConfig = (provider) => ({
|
|
247
|
+
...provider,
|
|
248
|
+
...(provider.scopes === undefined ? {} : { scopes: [...provider.scopes] }),
|
|
249
|
+
...(provider.authorizationParams === undefined
|
|
250
|
+
? {}
|
|
251
|
+
: { authorizationParams: { ...provider.authorizationParams } }),
|
|
252
|
+
...(provider.metadata === undefined ? {} : { metadata: metadataValue(provider.metadata) }),
|
|
253
|
+
});
|
|
254
|
+
const encodeOAuthState = (input) => OAuthStateToken(`${input.challengeId}.${Redacted.value(input.secret)}`);
|
|
255
|
+
export const parseOAuthState = (state) => {
|
|
256
|
+
const parts = String(state).split(".");
|
|
257
|
+
return parts.length === 2 && parts[0] !== "" && parts[1] !== ""
|
|
258
|
+
? Effect.succeed({
|
|
259
|
+
challengeId: ChallengeId(parts[0]),
|
|
260
|
+
secret: Redacted.make(parts[1]),
|
|
261
|
+
})
|
|
262
|
+
: Effect.fail(oauthStateError("parse", "OAuth state must use '<id>.<secret>' format"));
|
|
263
|
+
};
|
|
264
|
+
const stringArrayFromUnknown = (value) => Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
265
|
+
? value
|
|
266
|
+
: [];
|
|
267
|
+
const stringFromUnknown = (value) => typeof value === "string" && value.length > 0 ? value : undefined;
|
|
268
|
+
const nonEmptyStringFromUnknown = (value) => typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
269
|
+
export const githubOAuthVerifiedEmail = (emails) => {
|
|
270
|
+
for (const email of emails) {
|
|
271
|
+
if (email.primary === true && email.verified === true) {
|
|
272
|
+
const value = nonEmptyStringFromUnknown(email.email);
|
|
273
|
+
if (value !== undefined) {
|
|
274
|
+
return value;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const email of emails) {
|
|
279
|
+
if (email.verified === true) {
|
|
280
|
+
const value = nonEmptyStringFromUnknown(email.email);
|
|
281
|
+
if (value !== undefined) {
|
|
282
|
+
return value;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return undefined;
|
|
287
|
+
};
|
|
288
|
+
export const googleOidcHostedDomain = (value) => nonEmptyStringFromUnknown(value.hd);
|
|
289
|
+
export const microsoftOidcTenantId = (value) => nonEmptyStringFromUnknown(value.tid);
|
|
290
|
+
export const microsoftOidcObjectId = (value) => nonEmptyStringFromUnknown(value.oid);
|
|
291
|
+
export const microsoftOidcAccountId = (value) => {
|
|
292
|
+
const tenantId = microsoftOidcTenantId(value);
|
|
293
|
+
const objectId = microsoftOidcObjectId(value);
|
|
294
|
+
return tenantId === undefined || objectId === undefined
|
|
295
|
+
? undefined
|
|
296
|
+
: `${tenantId}:${objectId}`;
|
|
297
|
+
};
|
|
298
|
+
const optionalRecord = (value) => (Object.keys(value).length === 0 ? undefined : value);
|
|
299
|
+
const recordFromUnknown = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
300
|
+
? value
|
|
301
|
+
: undefined;
|
|
302
|
+
const isJwtAlgorithm = (value) => typeof value === "string" && JwtAlgorithms.includes(value);
|
|
303
|
+
const isPublicJwksAlgorithm = (value) => isJwtAlgorithm(value) && value !== "HS256";
|
|
304
|
+
const booleanFromUnknown = (value) => typeof value === "boolean" ? value : undefined;
|
|
305
|
+
const emailFromUnknown = (value) => {
|
|
306
|
+
const email = nonEmptyStringFromUnknown(value);
|
|
307
|
+
return email === undefined ? undefined : Email(email);
|
|
308
|
+
};
|
|
309
|
+
const appleSignInBooleanClaim = (value) => {
|
|
310
|
+
if (typeof value === "boolean") {
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
if (value === "true") {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
if (value === "false") {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
return undefined;
|
|
320
|
+
};
|
|
321
|
+
export const decodeAppleSignInAuthorizationUser = (value) => {
|
|
322
|
+
const decoded = typeof value === "string"
|
|
323
|
+
? (() => {
|
|
324
|
+
try {
|
|
325
|
+
return JSON.parse(value);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
})()
|
|
331
|
+
: value;
|
|
332
|
+
const record = recordFromUnknown(decoded);
|
|
333
|
+
if (record === undefined) {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
const nameRecord = recordFromUnknown(record.name);
|
|
337
|
+
const firstName = nonEmptyStringFromUnknown(nameRecord?.firstName);
|
|
338
|
+
const lastName = nonEmptyStringFromUnknown(nameRecord?.lastName);
|
|
339
|
+
const name = firstName === undefined && lastName === undefined
|
|
340
|
+
? undefined
|
|
341
|
+
: {
|
|
342
|
+
...(firstName === undefined ? {} : { firstName }),
|
|
343
|
+
...(lastName === undefined ? {} : { lastName }),
|
|
344
|
+
};
|
|
345
|
+
const email = nonEmptyStringFromUnknown(record.email);
|
|
346
|
+
return email === undefined && name === undefined
|
|
347
|
+
? undefined
|
|
348
|
+
: {
|
|
349
|
+
...(email === undefined ? {} : { email }),
|
|
350
|
+
...(name === undefined ? {} : { name }),
|
|
351
|
+
};
|
|
352
|
+
};
|
|
353
|
+
export const appleSignInAuthorizationUserFullName = (user) => {
|
|
354
|
+
const firstName = nonEmptyStringFromUnknown(user?.name?.firstName);
|
|
355
|
+
const lastName = nonEmptyStringFromUnknown(user?.name?.lastName);
|
|
356
|
+
const parts = [firstName, lastName].filter((part) => part !== undefined);
|
|
357
|
+
return parts.length === 0 ? undefined : parts.join(" ");
|
|
358
|
+
};
|
|
359
|
+
export const appleSignInAuthorizationUserEmail = (user) => emailFromUnknown(user?.email);
|
|
360
|
+
export const appleSignInIsPrivateEmail = (input) => {
|
|
361
|
+
const claimValue = appleSignInBooleanClaim(input.claims?.is_private_email);
|
|
362
|
+
if (claimValue !== undefined) {
|
|
363
|
+
return claimValue;
|
|
364
|
+
}
|
|
365
|
+
const email = nonEmptyStringFromUnknown(input.email);
|
|
366
|
+
return email === undefined
|
|
367
|
+
? undefined
|
|
368
|
+
: email.toLowerCase().endsWith(`@${appleSignInPrivateRelayDomain}`);
|
|
369
|
+
};
|
|
370
|
+
const firstString = (...values) => {
|
|
371
|
+
for (const value of values) {
|
|
372
|
+
const stringValue = nonEmptyStringFromUnknown(value);
|
|
373
|
+
if (stringValue !== undefined) {
|
|
374
|
+
return stringValue;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return undefined;
|
|
378
|
+
};
|
|
379
|
+
const optionalStringArrayFromUnknown = (value) => Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
380
|
+
? value
|
|
381
|
+
: undefined;
|
|
382
|
+
const numberFromUnknown = (value) => {
|
|
383
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
384
|
+
return value;
|
|
385
|
+
}
|
|
386
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
387
|
+
const parsed = Number(value);
|
|
388
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
389
|
+
}
|
|
390
|
+
return undefined;
|
|
391
|
+
};
|
|
392
|
+
const isTokenResponseRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
393
|
+
const scopeValues = (value) => {
|
|
394
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
return value.trim().split(/\s+/);
|
|
398
|
+
};
|
|
399
|
+
const audienceValues = (value) => {
|
|
400
|
+
if (typeof value === "string") {
|
|
401
|
+
return [value];
|
|
402
|
+
}
|
|
403
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
|
|
404
|
+
return value;
|
|
405
|
+
}
|
|
406
|
+
return [];
|
|
407
|
+
};
|
|
408
|
+
const oidcIdTokenClaims = (claims, input) => {
|
|
409
|
+
if (nonEmptyStringFromUnknown(claims.sub) === undefined) {
|
|
410
|
+
return "missing_subject";
|
|
411
|
+
}
|
|
412
|
+
if (typeof claims.exp !== "number" || !Number.isFinite(claims.exp)) {
|
|
413
|
+
return "missing_expiration";
|
|
414
|
+
}
|
|
415
|
+
if (typeof claims.iat !== "number" || !Number.isFinite(claims.iat)) {
|
|
416
|
+
return "missing_issued_at";
|
|
417
|
+
}
|
|
418
|
+
const audiences = audienceValues(claims.aud);
|
|
419
|
+
const authorizedParty = input.authorizedParty ?? input.provider.clientId;
|
|
420
|
+
if (audiences.length > 1 && claims.azp === undefined) {
|
|
421
|
+
return "missing_authorized_party";
|
|
422
|
+
}
|
|
423
|
+
if (claims.azp !== undefined && claims.azp !== authorizedParty) {
|
|
424
|
+
return "authorized_party_mismatch";
|
|
425
|
+
}
|
|
426
|
+
if (input.nonce !== undefined && claims.nonce !== String(input.nonce)) {
|
|
427
|
+
return "nonce_mismatch";
|
|
428
|
+
}
|
|
429
|
+
return claims;
|
|
430
|
+
};
|
|
431
|
+
const tokenResponseExtra = (value) => {
|
|
432
|
+
const extra = Object.fromEntries(Object.entries(value).filter(([key]) => ![
|
|
433
|
+
"access_token",
|
|
434
|
+
"token_type",
|
|
435
|
+
"expires_in",
|
|
436
|
+
"refresh_token",
|
|
437
|
+
"id_token",
|
|
438
|
+
"scope",
|
|
439
|
+
].includes(key)));
|
|
440
|
+
return Object.keys(extra).length === 0 ? undefined : extra;
|
|
441
|
+
};
|
|
442
|
+
const cloneVerifiedOAuthIdentity = (identity) => ({
|
|
443
|
+
...identity,
|
|
444
|
+
...(identity.metadata === undefined
|
|
445
|
+
? {}
|
|
446
|
+
: { metadata: metadataValue(identity.metadata) }),
|
|
447
|
+
});
|
|
448
|
+
const oauthLinkConfirmationChallengeType = "oauth-link-confirmation";
|
|
449
|
+
const oauthLinkConfirmationMetadata = (input) => ({
|
|
450
|
+
userId: input.userId,
|
|
451
|
+
providerId: input.identity.providerId,
|
|
452
|
+
providerAccountId: input.identity.providerAccountId,
|
|
453
|
+
...(input.identity.email === undefined ? {} : { email: input.identity.email }),
|
|
454
|
+
...(input.identity.emailVerified === undefined
|
|
455
|
+
? {}
|
|
456
|
+
: { emailVerified: input.identity.emailVerified }),
|
|
457
|
+
...(input.identity.metadata === undefined
|
|
458
|
+
? {}
|
|
459
|
+
: { identityMetadata: metadataValue(input.identity.metadata) }),
|
|
460
|
+
reason: input.reason,
|
|
461
|
+
...(input.metadata === undefined ? {} : { metadata: metadataValue(input.metadata) }),
|
|
462
|
+
});
|
|
463
|
+
const pendingOAuthLinkConfirmationFromChallenge = (challenge, operation) => {
|
|
464
|
+
const metadata = challenge.metadata ?? {};
|
|
465
|
+
const userId = stringFromUnknown(metadata.userId);
|
|
466
|
+
const providerId = stringFromUnknown(metadata.providerId);
|
|
467
|
+
const providerAccountId = stringFromUnknown(metadata.providerAccountId);
|
|
468
|
+
if (userId === undefined ||
|
|
469
|
+
userId !== challenge.subject ||
|
|
470
|
+
providerId === undefined ||
|
|
471
|
+
providerAccountId === undefined) {
|
|
472
|
+
return Effect.fail(oauthLinkConfirmationError(operation, "OAuth link confirmation challenge is missing required metadata"));
|
|
473
|
+
}
|
|
474
|
+
const email = emailFromUnknown(metadata.email);
|
|
475
|
+
const emailVerified = booleanFromUnknown(metadata.emailVerified);
|
|
476
|
+
const identityMetadata = recordFromUnknown(metadata.identityMetadata);
|
|
477
|
+
const appMetadata = recordFromUnknown(metadata.metadata);
|
|
478
|
+
return Effect.succeed({
|
|
479
|
+
challengeId: challenge.id,
|
|
480
|
+
userId: UserId(userId),
|
|
481
|
+
identity: {
|
|
482
|
+
providerId: OAuthProviderId(providerId),
|
|
483
|
+
providerAccountId,
|
|
484
|
+
...(email === undefined ? {} : { email }),
|
|
485
|
+
...(emailVerified === undefined ? {} : { emailVerified }),
|
|
486
|
+
...(identityMetadata === undefined
|
|
487
|
+
? {}
|
|
488
|
+
: { metadata: metadataValue(identityMetadata) }),
|
|
489
|
+
},
|
|
490
|
+
reason: stringFromUnknown(metadata.reason) ??
|
|
491
|
+
"manual",
|
|
492
|
+
expiresAt: challenge.expiresAt,
|
|
493
|
+
...(appMetadata === undefined ? {} : { metadata: metadataValue(appMetadata) }),
|
|
494
|
+
});
|
|
495
|
+
};
|
|
496
|
+
const confirmedOAuthLinkConfirmationFromPending = (input) => ({
|
|
497
|
+
challengeId: input.pending.challengeId,
|
|
498
|
+
userId: input.pending.userId,
|
|
499
|
+
identity: cloneVerifiedOAuthIdentity(input.pending.identity),
|
|
500
|
+
reason: input.pending.reason,
|
|
501
|
+
...(input.existingAccount === undefined
|
|
502
|
+
? {}
|
|
503
|
+
: { existingAccount: input.existingAccount }),
|
|
504
|
+
...(input.pending.metadata === undefined
|
|
505
|
+
? {}
|
|
506
|
+
: { metadata: metadataValue(input.pending.metadata) }),
|
|
507
|
+
});
|
|
508
|
+
const userInfoRecord = (value) => {
|
|
509
|
+
if (value === undefined) {
|
|
510
|
+
return Effect.succeed(undefined);
|
|
511
|
+
}
|
|
512
|
+
const record = recordFromUnknown(value);
|
|
513
|
+
return record === undefined
|
|
514
|
+
? Effect.fail(oauthProfileNormalizationError("invalid_userinfo", "OIDC userinfo response must be an object"))
|
|
515
|
+
: Effect.succeed(record);
|
|
516
|
+
};
|
|
517
|
+
const makeOAuthProfile = (input) => ({
|
|
518
|
+
providerId: input.providerId,
|
|
519
|
+
providerAccountId: input.providerAccountId,
|
|
520
|
+
...(input.email === undefined ? {} : { email: input.email }),
|
|
521
|
+
...(input.emailVerified === undefined
|
|
522
|
+
? {}
|
|
523
|
+
: { emailVerified: input.emailVerified }),
|
|
524
|
+
...(input.name === undefined ? {} : { name: input.name }),
|
|
525
|
+
...(input.username === undefined ? {} : { username: input.username }),
|
|
526
|
+
...(input.avatarUrl === undefined ? {} : { avatarUrl: input.avatarUrl }),
|
|
527
|
+
...(input.metadata === undefined ? {} : { metadata: metadataValue(input.metadata) }),
|
|
528
|
+
});
|
|
529
|
+
const oauthJwksPublicJwkFromUnknown = (value) => {
|
|
530
|
+
const record = recordFromUnknown(value);
|
|
531
|
+
const kty = record === undefined ? undefined : nonEmptyStringFromUnknown(record.kty);
|
|
532
|
+
if (record === undefined || kty === undefined) {
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
const jwk = {};
|
|
536
|
+
for (const [name, member] of Object.entries(record)) {
|
|
537
|
+
if (!oauthJwksPrivateMembers.has(name) && member !== undefined) {
|
|
538
|
+
jwk[name] = member;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
...jwk,
|
|
543
|
+
kty,
|
|
544
|
+
};
|
|
545
|
+
};
|
|
546
|
+
const oauthJwksAllowsSignatureUse = (jwk) => {
|
|
547
|
+
const use = nonEmptyStringFromUnknown(jwk.use);
|
|
548
|
+
const keyOps = optionalStringArrayFromUnknown(jwk.key_ops);
|
|
549
|
+
return ((use === undefined || use === "sig") &&
|
|
550
|
+
(keyOps === undefined || keyOps.includes("verify")));
|
|
551
|
+
};
|
|
552
|
+
const oauthJwksDefaultAlgorithms = (jwk) => {
|
|
553
|
+
switch (jwk.kty) {
|
|
554
|
+
case "RSA": {
|
|
555
|
+
return ["RS256"];
|
|
556
|
+
}
|
|
557
|
+
case "EC": {
|
|
558
|
+
return ["ES256"];
|
|
559
|
+
}
|
|
560
|
+
case "OKP": {
|
|
561
|
+
return ["EdDSA"];
|
|
562
|
+
}
|
|
563
|
+
default: {
|
|
564
|
+
return [];
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
const oauthJwksAlgorithms = (jwk, allowedAlgorithms) => {
|
|
569
|
+
const jwkAlgorithm = nonEmptyStringFromUnknown(jwk.alg);
|
|
570
|
+
const algorithms = jwkAlgorithm === undefined
|
|
571
|
+
? oauthJwksDefaultAlgorithms(jwk)
|
|
572
|
+
: isPublicJwksAlgorithm(jwkAlgorithm)
|
|
573
|
+
? [jwkAlgorithm]
|
|
574
|
+
: [];
|
|
575
|
+
if (allowedAlgorithms === undefined) {
|
|
576
|
+
return algorithms;
|
|
577
|
+
}
|
|
578
|
+
return algorithms.filter((algorithm) => allowedAlgorithms.includes(algorithm));
|
|
579
|
+
};
|
|
580
|
+
const oauthJwksRecordsFromDocument = (provider, document, allowedAlgorithms) => document.keys.flatMap((jwk) => {
|
|
581
|
+
const kid = nonEmptyStringFromUnknown(jwk.kid);
|
|
582
|
+
if (kid === undefined || !oauthJwksAllowsSignatureUse(jwk)) {
|
|
583
|
+
return [];
|
|
584
|
+
}
|
|
585
|
+
return oauthJwksAlgorithms(jwk, allowedAlgorithms).map((alg) => ({
|
|
586
|
+
id: JwtKeyId(kid),
|
|
587
|
+
alg,
|
|
588
|
+
status: "active",
|
|
589
|
+
publicJwk: {
|
|
590
|
+
...jwk,
|
|
591
|
+
kid,
|
|
592
|
+
alg,
|
|
593
|
+
use: nonEmptyStringFromUnknown(jwk.use) ?? "sig",
|
|
594
|
+
},
|
|
595
|
+
metadata: {
|
|
596
|
+
providerId: provider.id,
|
|
597
|
+
},
|
|
598
|
+
}));
|
|
599
|
+
});
|
|
600
|
+
const oauthJwksKeyUsableAt = (key, now) => now === undefined || key.expiresAt === undefined || Number(now) < Number(key.expiresAt);
|
|
601
|
+
const oauthJwksVerificationKeyMatches = (key, input) => key.id === input.kid &&
|
|
602
|
+
key.status !== "disabled" &&
|
|
603
|
+
key.publicJwk !== undefined &&
|
|
604
|
+
(input.alg === undefined || key.alg === input.alg) &&
|
|
605
|
+
oauthJwksKeyUsableAt(key, input.now);
|
|
606
|
+
const oauthJwksNow = (now) => now === undefined ? currentUnixMillis : Effect.succeed(now);
|
|
607
|
+
const formUrlEncode = (value) => new URLSearchParams([["", value]]).toString().slice(1);
|
|
608
|
+
const clientSecretBasic = (provider, secret) => `Basic ${Encoding.encodeBase64(textEncoder.encode(`${formUrlEncode(provider.clientId)}:${formUrlEncode(secret)}`))}`;
|
|
609
|
+
const oauthTokenRequestHeaders = () => ({
|
|
610
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
611
|
+
accept: "application/json",
|
|
612
|
+
});
|
|
613
|
+
const oauthRequestHeadersWithoutAuthorization = (headers) => {
|
|
614
|
+
const value = {};
|
|
615
|
+
for (const [name, headerValue] of Object.entries(headers ?? {})) {
|
|
616
|
+
if (name.toLowerCase() !== "authorization") {
|
|
617
|
+
value[name] = headerValue;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return value;
|
|
621
|
+
};
|
|
622
|
+
const applyOAuthClientAuthentication = (input) => {
|
|
623
|
+
const clientAuthentication = input.clientAuthentication ?? { method: "none" };
|
|
624
|
+
if (clientAuthentication.method === "client_secret_basic") {
|
|
625
|
+
input.body.delete("client_id");
|
|
626
|
+
input.body.delete("client_secret");
|
|
627
|
+
input.headers.authorization = clientSecretBasic(input.provider, Redacted.value(clientAuthentication.clientSecret));
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
input.body.set("client_id", input.provider.clientId);
|
|
631
|
+
}
|
|
632
|
+
if (clientAuthentication.method === "client_secret_post") {
|
|
633
|
+
input.body.set("client_secret", Redacted.value(clientAuthentication.clientSecret));
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
export const makeOAuthTokenRequest = (input) => Effect.try({
|
|
637
|
+
try: () => {
|
|
638
|
+
if (input.provider.tokenEndpoint === undefined) {
|
|
639
|
+
throw new Error("OAuth provider tokenEndpoint is required");
|
|
640
|
+
}
|
|
641
|
+
const body = new URLSearchParams(input.params);
|
|
642
|
+
const headers = oauthTokenRequestHeaders();
|
|
643
|
+
body.set("grant_type", "authorization_code");
|
|
644
|
+
body.set("code", String(input.code));
|
|
645
|
+
body.set("redirect_uri", input.redirectUri ?? input.provider.redirectUri);
|
|
646
|
+
body.set("code_verifier", Redacted.value(input.codeVerifier));
|
|
647
|
+
applyOAuthClientAuthentication({
|
|
648
|
+
provider: input.provider,
|
|
649
|
+
body,
|
|
650
|
+
headers,
|
|
651
|
+
clientAuthentication: input.clientAuthentication,
|
|
652
|
+
});
|
|
653
|
+
return {
|
|
654
|
+
url: input.provider.tokenEndpoint,
|
|
655
|
+
method: "POST",
|
|
656
|
+
headers,
|
|
657
|
+
body: body.toString(),
|
|
658
|
+
};
|
|
659
|
+
},
|
|
660
|
+
catch: (cause) => oauthTokenExchangeError("token-request", "Failed to build OAuth token request", cause),
|
|
661
|
+
});
|
|
662
|
+
export const makeOAuthRefreshTokenRequest = (input) => Effect.try({
|
|
663
|
+
try: () => {
|
|
664
|
+
if (input.provider.tokenEndpoint === undefined) {
|
|
665
|
+
throw new Error("OAuth provider tokenEndpoint is required");
|
|
666
|
+
}
|
|
667
|
+
const body = new URLSearchParams(input.params);
|
|
668
|
+
const headers = oauthTokenRequestHeaders();
|
|
669
|
+
body.set("grant_type", "refresh_token");
|
|
670
|
+
body.set("refresh_token", Redacted.value(input.refreshToken));
|
|
671
|
+
if (input.scopes !== undefined) {
|
|
672
|
+
body.set("scope", input.scopes.join(" "));
|
|
673
|
+
}
|
|
674
|
+
applyOAuthClientAuthentication({
|
|
675
|
+
provider: input.provider,
|
|
676
|
+
body,
|
|
677
|
+
headers,
|
|
678
|
+
clientAuthentication: input.clientAuthentication,
|
|
679
|
+
});
|
|
680
|
+
return {
|
|
681
|
+
url: input.provider.tokenEndpoint,
|
|
682
|
+
method: "POST",
|
|
683
|
+
headers,
|
|
684
|
+
body: body.toString(),
|
|
685
|
+
};
|
|
686
|
+
},
|
|
687
|
+
catch: (cause) => oauthTokenExchangeError("refresh-token-request", "Failed to build OAuth refresh token request", cause),
|
|
688
|
+
});
|
|
689
|
+
export const makeOAuthTokenRevocationRequest = (input) => Effect.try({
|
|
690
|
+
try: () => {
|
|
691
|
+
if (input.provider.revocationEndpoint === undefined) {
|
|
692
|
+
throw new Error("OAuth provider revocationEndpoint is required");
|
|
693
|
+
}
|
|
694
|
+
const body = new URLSearchParams(input.params);
|
|
695
|
+
const headers = oauthTokenRequestHeaders();
|
|
696
|
+
body.set("token", Redacted.value(input.token));
|
|
697
|
+
if (input.tokenTypeHint !== undefined) {
|
|
698
|
+
body.set("token_type_hint", input.tokenTypeHint);
|
|
699
|
+
}
|
|
700
|
+
applyOAuthClientAuthentication({
|
|
701
|
+
provider: input.provider,
|
|
702
|
+
body,
|
|
703
|
+
headers,
|
|
704
|
+
clientAuthentication: input.clientAuthentication,
|
|
705
|
+
});
|
|
706
|
+
return {
|
|
707
|
+
url: input.provider.revocationEndpoint,
|
|
708
|
+
method: "POST",
|
|
709
|
+
headers,
|
|
710
|
+
body: body.toString(),
|
|
711
|
+
};
|
|
712
|
+
},
|
|
713
|
+
catch: (cause) => oauthTokenExchangeError("revocation-request", "Failed to build OAuth token revocation request", cause),
|
|
714
|
+
});
|
|
715
|
+
export const makeOAuthUserInfoRequest = (input) => Effect.try({
|
|
716
|
+
try: () => {
|
|
717
|
+
if (input.provider.userInfoEndpoint === undefined) {
|
|
718
|
+
throw new Error("OAuth provider userInfoEndpoint is required");
|
|
719
|
+
}
|
|
720
|
+
const url = new URL(input.provider.userInfoEndpoint);
|
|
721
|
+
for (const [name, value] of Object.entries(input.params ?? {})) {
|
|
722
|
+
url.searchParams.set(name, value);
|
|
723
|
+
}
|
|
724
|
+
return {
|
|
725
|
+
url: url.toString(),
|
|
726
|
+
method: "GET",
|
|
727
|
+
headers: {
|
|
728
|
+
accept: "application/json",
|
|
729
|
+
...oauthRequestHeadersWithoutAuthorization(input.headers),
|
|
730
|
+
authorization: `Bearer ${Redacted.value(input.accessToken)}`,
|
|
731
|
+
},
|
|
732
|
+
};
|
|
733
|
+
},
|
|
734
|
+
catch: (cause) => oauthUserInfoError("request", "Failed to build OAuth userinfo request", cause),
|
|
735
|
+
});
|
|
736
|
+
export const decodeOAuthUserInfoResponse = (value) => {
|
|
737
|
+
const record = recordFromUnknown(value);
|
|
738
|
+
return record === undefined
|
|
739
|
+
? Effect.fail(oauthUserInfoError("decode", "OAuth userinfo response must be an object"))
|
|
740
|
+
: Effect.succeed(metadataValue(record) ?? {});
|
|
741
|
+
};
|
|
742
|
+
export const decodeOAuthTokenResponse = (value) => Effect.gen(function* () {
|
|
743
|
+
if (!isTokenResponseRecord(value)) {
|
|
744
|
+
return yield* oauthTokenExchangeError("decode-token-response", "OAuth token response must be an object");
|
|
745
|
+
}
|
|
746
|
+
const accessToken = stringFromUnknown(value.access_token);
|
|
747
|
+
const tokenType = stringFromUnknown(value.token_type);
|
|
748
|
+
if (accessToken === undefined) {
|
|
749
|
+
return yield* oauthTokenExchangeError("decode-token-response", "OAuth token response is missing access_token");
|
|
750
|
+
}
|
|
751
|
+
if (tokenType === undefined) {
|
|
752
|
+
return yield* oauthTokenExchangeError("decode-token-response", "OAuth token response is missing token_type");
|
|
753
|
+
}
|
|
754
|
+
const refreshToken = stringFromUnknown(value.refresh_token);
|
|
755
|
+
const idToken = stringFromUnknown(value.id_token);
|
|
756
|
+
return {
|
|
757
|
+
accessToken: Redacted.make(OAuthAccessToken(accessToken)),
|
|
758
|
+
tokenType,
|
|
759
|
+
expiresIn: numberFromUnknown(value.expires_in),
|
|
760
|
+
...(refreshToken === undefined
|
|
761
|
+
? {}
|
|
762
|
+
: { refreshToken: Redacted.make(OAuthRefreshToken(refreshToken)) }),
|
|
763
|
+
...(idToken === undefined
|
|
764
|
+
? {}
|
|
765
|
+
: { idToken: Redacted.make(OAuthIdToken(idToken)) }),
|
|
766
|
+
scopes: scopeValues(value.scope),
|
|
767
|
+
extra: tokenResponseExtra(value),
|
|
768
|
+
};
|
|
769
|
+
});
|
|
770
|
+
export const makeOidcIdTokenVerifier = (dependencies) => OidcIdTokenVerifier.make({
|
|
771
|
+
verify: Effect.fn("auth.oidc_id_token.verify")(function* (input) {
|
|
772
|
+
const { provider } = input;
|
|
773
|
+
const { issuer } = provider;
|
|
774
|
+
if (issuer === undefined) {
|
|
775
|
+
return {
|
|
776
|
+
valid: false,
|
|
777
|
+
reason: "missing_provider_issuer",
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
const verified = yield* dependencies.jwtVerifier
|
|
781
|
+
.verify({
|
|
782
|
+
token: Redacted.make(Redacted.value(input.idToken)),
|
|
783
|
+
issuer,
|
|
784
|
+
audience: input.audience ?? provider.clientId,
|
|
785
|
+
now: input.now,
|
|
786
|
+
clockTolerance: input.clockTolerance,
|
|
787
|
+
})
|
|
788
|
+
.pipe(Effect.mapError((cause) => oidcIdTokenVerifierError("verify", "Failed to verify OIDC ID token JWT", cause)));
|
|
789
|
+
if (!verified.valid) {
|
|
790
|
+
return verified;
|
|
791
|
+
}
|
|
792
|
+
const claims = oidcIdTokenClaims(verified.claims, input);
|
|
793
|
+
if (typeof claims === "string") {
|
|
794
|
+
return { valid: false, reason: claims };
|
|
795
|
+
}
|
|
796
|
+
return {
|
|
797
|
+
valid: true,
|
|
798
|
+
header: verified.header,
|
|
799
|
+
claims,
|
|
800
|
+
key: verified.key,
|
|
801
|
+
};
|
|
802
|
+
}),
|
|
803
|
+
});
|
|
804
|
+
export const verifyOidcIdToken = (input) => OidcIdTokenVerifier.use((verifier) => verifier.verify(input));
|
|
805
|
+
export const decodeOAuthJwksDocument = (input) => Effect.gen(function* () {
|
|
806
|
+
const record = recordFromUnknown(input);
|
|
807
|
+
if (record === undefined || !Array.isArray(record.keys)) {
|
|
808
|
+
return yield* oauthJwksJwtError("OAuth JWKS document must contain keys");
|
|
809
|
+
}
|
|
810
|
+
const keys = [];
|
|
811
|
+
for (const key of record.keys) {
|
|
812
|
+
const publicJwk = oauthJwksPublicJwkFromUnknown(key);
|
|
813
|
+
if (publicJwk === undefined) {
|
|
814
|
+
return yield* oauthJwksJwtError("OAuth JWKS key must be a public JWK");
|
|
815
|
+
}
|
|
816
|
+
keys.push(publicJwk);
|
|
817
|
+
}
|
|
818
|
+
return { keys };
|
|
819
|
+
});
|
|
820
|
+
export const makeOAuthJwksJwtKeys = (dependencies) => {
|
|
821
|
+
const cacheTtlMillis = Math.max(0, Duration.toMillis(dependencies.cacheTtl ?? defaultOAuthJwksCacheTtl));
|
|
822
|
+
const allowedAlgorithms = dependencies.allowedAlgorithms?.filter(isPublicJwksAlgorithm);
|
|
823
|
+
let cache;
|
|
824
|
+
const loadRecords = (now) => Effect.gen(function* () {
|
|
825
|
+
if (cache !== undefined && Number(now) < Number(cache.expiresAt)) {
|
|
826
|
+
return cache.records;
|
|
827
|
+
}
|
|
828
|
+
const url = nonEmptyStringFromUnknown(dependencies.provider.jwksUri);
|
|
829
|
+
if (url === undefined) {
|
|
830
|
+
return yield* oauthJwksJwtError("OAuth provider JWKS URI is required");
|
|
831
|
+
}
|
|
832
|
+
const rawDocument = yield* dependencies.fetch({
|
|
833
|
+
provider: dependencies.provider,
|
|
834
|
+
url,
|
|
835
|
+
});
|
|
836
|
+
const document = yield* decodeOAuthJwksDocument(rawDocument);
|
|
837
|
+
const records = oauthJwksRecordsFromDocument(dependencies.provider, document, allowedAlgorithms);
|
|
838
|
+
cache = {
|
|
839
|
+
records,
|
|
840
|
+
expiresAt: UnixMillis(Number(now) + cacheTtlMillis),
|
|
841
|
+
};
|
|
842
|
+
return records;
|
|
843
|
+
});
|
|
844
|
+
return JwtKeys.make({
|
|
845
|
+
selectSigningKey: () => Effect.succeed(Option.none()),
|
|
846
|
+
findVerificationKey: Effect.fn("auth.oauth_jwks.find_verification_key")(function* (input) {
|
|
847
|
+
const now = yield* oauthJwksNow(input.now);
|
|
848
|
+
const records = yield* loadRecords(now);
|
|
849
|
+
const key = records.find((record) => oauthJwksVerificationKeyMatches(record, { ...input, now }));
|
|
850
|
+
return key === undefined ? Option.none() : Option.some(key);
|
|
851
|
+
}),
|
|
852
|
+
listPublicJwks: Effect.fn("auth.oauth_jwks.list_public_jwks")(function* (input) {
|
|
853
|
+
const now = yield* oauthJwksNow(input?.now);
|
|
854
|
+
const records = yield* loadRecords(now);
|
|
855
|
+
return records.flatMap((record) => record.publicJwk === undefined || !oauthJwksKeyUsableAt(record, now)
|
|
856
|
+
? []
|
|
857
|
+
: [record.publicJwk]);
|
|
858
|
+
}),
|
|
859
|
+
});
|
|
860
|
+
};
|
|
861
|
+
export const normalizeOidcProfile = (input) => Effect.gen(function* () {
|
|
862
|
+
const { claims, provider } = input;
|
|
863
|
+
if (claims === undefined) {
|
|
864
|
+
return yield* oauthProfileNormalizationError("missing_claims", "OIDC ID token claims are required to normalize an OAuth profile");
|
|
865
|
+
}
|
|
866
|
+
const subject = nonEmptyStringFromUnknown(claims.sub);
|
|
867
|
+
if (subject === undefined) {
|
|
868
|
+
return yield* oauthProfileNormalizationError("missing_subject", "OIDC profile is missing subject");
|
|
869
|
+
}
|
|
870
|
+
const userInfo = yield* userInfoRecord(input.userInfo);
|
|
871
|
+
const userInfoSubject = userInfo === undefined ? undefined : nonEmptyStringFromUnknown(userInfo.sub);
|
|
872
|
+
if (userInfoSubject !== undefined && userInfoSubject !== subject) {
|
|
873
|
+
return yield* oauthProfileNormalizationError("subject_mismatch", "OIDC userinfo subject does not match ID token subject");
|
|
874
|
+
}
|
|
875
|
+
const email = emailFromUnknown(userInfo?.email) ?? emailFromUnknown(claims.email);
|
|
876
|
+
const emailVerified = booleanFromUnknown(userInfo?.email_verified) ??
|
|
877
|
+
booleanFromUnknown(claims.email_verified);
|
|
878
|
+
const metadata = optionalRecord({
|
|
879
|
+
...(input.metadata === undefined ? {} : input.metadata),
|
|
880
|
+
});
|
|
881
|
+
return makeOAuthProfile({
|
|
882
|
+
providerId: provider.id,
|
|
883
|
+
providerAccountId: subject,
|
|
884
|
+
email,
|
|
885
|
+
emailVerified,
|
|
886
|
+
name: firstString(userInfo?.name, claims.name),
|
|
887
|
+
username: firstString(userInfo?.preferred_username, claims.preferred_username),
|
|
888
|
+
avatarUrl: firstString(userInfo?.picture, claims.picture),
|
|
889
|
+
metadata,
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
export const makeOidcOAuthProfileNormalizer = () => OAuthProfileNormalizer.make({
|
|
893
|
+
normalize: Effect.fn("auth.oauth_profile.normalize_oidc")(normalizeOidcProfile),
|
|
894
|
+
});
|
|
895
|
+
export const normalizeOAuthProfile = (input) => OAuthProfileNormalizer.use((normalizer) => normalizer.normalize(input));
|
|
896
|
+
export const verifiedOAuthIdentityFromProfile = (profile) => {
|
|
897
|
+
const metadata = optionalRecord({
|
|
898
|
+
...(profile.name === undefined ? {} : { name: profile.name }),
|
|
899
|
+
...(profile.username === undefined ? {} : { username: profile.username }),
|
|
900
|
+
...(profile.avatarUrl === undefined ? {} : { avatarUrl: profile.avatarUrl }),
|
|
901
|
+
...(profile.metadata === undefined ? {} : profile.metadata),
|
|
902
|
+
});
|
|
903
|
+
return {
|
|
904
|
+
providerId: profile.providerId,
|
|
905
|
+
providerAccountId: profile.providerAccountId,
|
|
906
|
+
...(profile.email === undefined ? {} : { email: profile.email }),
|
|
907
|
+
...(profile.emailVerified === undefined
|
|
908
|
+
? {}
|
|
909
|
+
: { emailVerified: profile.emailVerified }),
|
|
910
|
+
...(metadata === undefined ? {} : { metadata: metadataValue(metadata) }),
|
|
911
|
+
};
|
|
912
|
+
};
|
|
913
|
+
export const makeOAuthTokenExchange = (dependencies) => OAuthTokenExchange.make({
|
|
914
|
+
exchangeAuthorizationCode: Effect.fn("auth.oauth_token.exchange_code")(function* (input) {
|
|
915
|
+
const request = yield* makeOAuthTokenRequest(input);
|
|
916
|
+
const response = yield* dependencies.request(request);
|
|
917
|
+
return yield* decodeOAuthTokenResponse(response).pipe(Effect.mapError((cause) => oauthTokenExchangeError("exchange-code", "Failed to decode OAuth token exchange response", cause)));
|
|
918
|
+
}),
|
|
919
|
+
});
|
|
920
|
+
export const completeOAuthCallback = (input) => Effect.gen(function* () {
|
|
921
|
+
const oauthState = yield* OAuthState;
|
|
922
|
+
const tokenExchange = yield* OAuthTokenExchange;
|
|
923
|
+
const verifiedState = yield* oauthState.verify({ state: input.state });
|
|
924
|
+
if (verifiedState.providerId !== input.provider.id) {
|
|
925
|
+
return yield* oauthStateError("verify", "OAuth callback provider does not match verified state");
|
|
926
|
+
}
|
|
927
|
+
const tokens = yield* tokenExchange.exchangeAuthorizationCode({
|
|
928
|
+
provider: input.provider,
|
|
929
|
+
code: input.code,
|
|
930
|
+
codeVerifier: input.codeVerifier,
|
|
931
|
+
redirectUri: input.redirectUri ?? verifiedState.redirectUri ?? input.provider.redirectUri,
|
|
932
|
+
clientAuthentication: input.clientAuthentication,
|
|
933
|
+
params: input.tokenParams,
|
|
934
|
+
});
|
|
935
|
+
return { state: verifiedState, tokens };
|
|
936
|
+
});
|
|
937
|
+
export const makeOAuthAccountLinkingPolicy = (dependencies) => OAuthAccountLinking.make({
|
|
938
|
+
resolve: Effect.fn("auth.oauth_account_linking.resolve")(function* (input) {
|
|
939
|
+
const identity = cloneVerifiedOAuthIdentity(input.identity);
|
|
940
|
+
const linked = yield* dependencies.accounts
|
|
941
|
+
.findByProviderAccount({
|
|
942
|
+
providerId: identity.providerId,
|
|
943
|
+
providerAccountId: identity.providerAccountId,
|
|
944
|
+
})
|
|
945
|
+
.pipe(Effect.mapError((cause) => oauthAccountLinkingError("resolve", "Failed to look up OAuth provider account", cause)));
|
|
946
|
+
if (Option.isSome(linked)) {
|
|
947
|
+
const account = linked.value;
|
|
948
|
+
if (input.currentUserId !== undefined &&
|
|
949
|
+
account.userId !== input.currentUserId) {
|
|
950
|
+
return {
|
|
951
|
+
type: "deny",
|
|
952
|
+
reason: "provider-account-linked-to-different-user",
|
|
953
|
+
identity,
|
|
954
|
+
account,
|
|
955
|
+
currentUserId: input.currentUserId,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
return {
|
|
959
|
+
type: "sign-in",
|
|
960
|
+
reason: "linked-account",
|
|
961
|
+
userId: account.userId,
|
|
962
|
+
account,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
if (input.currentUserId !== undefined) {
|
|
966
|
+
return {
|
|
967
|
+
type: "link-existing-user",
|
|
968
|
+
reason: "current-user",
|
|
969
|
+
userId: input.currentUserId,
|
|
970
|
+
identity,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
if (input.matchedUserId !== undefined) {
|
|
974
|
+
if (dependencies.autoLinkVerifiedEmail === true &&
|
|
975
|
+
identity.emailVerified === true) {
|
|
976
|
+
return {
|
|
977
|
+
type: "link-existing-user",
|
|
978
|
+
reason: "verified-email",
|
|
979
|
+
userId: input.matchedUserId,
|
|
980
|
+
identity,
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
return {
|
|
984
|
+
type: "require-explicit-linking",
|
|
985
|
+
reason: identity.emailVerified === true ? "matched-email" : "unverified-email",
|
|
986
|
+
identity,
|
|
987
|
+
matchedUserId: input.matchedUserId,
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
return {
|
|
991
|
+
type: "require-explicit-linking",
|
|
992
|
+
reason: "no-linked-account",
|
|
993
|
+
identity,
|
|
994
|
+
};
|
|
995
|
+
}),
|
|
996
|
+
});
|
|
997
|
+
export const resolveOAuthAccountLinking = (input) => OAuthAccountLinking.use((linking) => linking.resolve(input));
|
|
998
|
+
export const makeOAuthAccountUnlinkingPolicy = (dependencies) => OAuthAccountUnlinking.make({
|
|
999
|
+
resolve: Effect.fn("auth.oauth_account_unlinking.resolve")(function* (input) {
|
|
1000
|
+
const account = yield* dependencies.accounts
|
|
1001
|
+
.findByProviderAccount({
|
|
1002
|
+
providerId: input.providerId,
|
|
1003
|
+
providerAccountId: input.providerAccountId,
|
|
1004
|
+
})
|
|
1005
|
+
.pipe(Effect.mapError((cause) => oauthAccountUnlinkingError("resolve", "Failed to look up OAuth provider account", cause)));
|
|
1006
|
+
if (Option.isNone(account)) {
|
|
1007
|
+
return {
|
|
1008
|
+
type: "deny-not-found",
|
|
1009
|
+
reason: "provider-account-not-found",
|
|
1010
|
+
userId: input.userId,
|
|
1011
|
+
providerId: input.providerId,
|
|
1012
|
+
providerAccountId: input.providerAccountId,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
const linkedAccount = account.value;
|
|
1016
|
+
if (linkedAccount.userId !== input.userId) {
|
|
1017
|
+
return {
|
|
1018
|
+
type: "deny-not-owner",
|
|
1019
|
+
reason: "provider-account-linked-to-different-user",
|
|
1020
|
+
userId: input.userId,
|
|
1021
|
+
account: linkedAccount,
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
const remainingAccounts = yield* dependencies.accounts
|
|
1025
|
+
.listByUser({ userId: input.userId })
|
|
1026
|
+
.pipe(Effect.map((accounts) => accounts.filter((linked) => linked.id !== linkedAccount.id)), Effect.mapError((cause) => oauthAccountUnlinkingError("resolve", "Failed to list OAuth provider accounts for user", cause)));
|
|
1027
|
+
if (dependencies.allowLastAccount !== true && remainingAccounts.length === 0) {
|
|
1028
|
+
return {
|
|
1029
|
+
type: "deny-last-account",
|
|
1030
|
+
reason: "last-linked-account",
|
|
1031
|
+
userId: input.userId,
|
|
1032
|
+
account: linkedAccount,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
if (dependencies.requireStepUp === true && input.stepUpSatisfied !== true) {
|
|
1036
|
+
return {
|
|
1037
|
+
type: "requires-step-up",
|
|
1038
|
+
reason: "step-up-required",
|
|
1039
|
+
userId: input.userId,
|
|
1040
|
+
account: linkedAccount,
|
|
1041
|
+
remainingAccounts,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
return {
|
|
1045
|
+
type: "allow",
|
|
1046
|
+
reason: "owned-account",
|
|
1047
|
+
userId: input.userId,
|
|
1048
|
+
account: linkedAccount,
|
|
1049
|
+
remainingAccounts,
|
|
1050
|
+
};
|
|
1051
|
+
}),
|
|
1052
|
+
});
|
|
1053
|
+
export const resolveOAuthAccountUnlinking = (input) => OAuthAccountUnlinking.use((unlinking) => unlinking.resolve(input));
|
|
1054
|
+
const findOAuthLinkConfirmationExistingAccount = (dependencies, input) => dependencies.accounts
|
|
1055
|
+
.findByProviderAccount({
|
|
1056
|
+
providerId: input.identity.providerId,
|
|
1057
|
+
providerAccountId: input.identity.providerAccountId,
|
|
1058
|
+
})
|
|
1059
|
+
.pipe(Effect.mapError((cause) => oauthLinkConfirmationError(input.operation, "Failed to look up OAuth provider account", cause)), Effect.flatMap((account) => {
|
|
1060
|
+
if (Option.isNone(account)) {
|
|
1061
|
+
return Effect.succeed(account);
|
|
1062
|
+
}
|
|
1063
|
+
if (account.value.userId !== input.userId) {
|
|
1064
|
+
return Effect.fail(oauthLinkConfirmationError(input.operation, "OAuth provider account is linked to a different user"));
|
|
1065
|
+
}
|
|
1066
|
+
return Effect.succeed(account);
|
|
1067
|
+
}));
|
|
1068
|
+
export const makeOAuthLinkConfirmation = (dependencies) => OAuthLinkConfirmation.make({
|
|
1069
|
+
start: Effect.fn("auth.oauth_link_confirmation.start")(function* (input) {
|
|
1070
|
+
const identity = cloneVerifiedOAuthIdentity(input.identity);
|
|
1071
|
+
const reason = input.reason ?? "manual";
|
|
1072
|
+
const existing = yield* findOAuthLinkConfirmationExistingAccount(dependencies, {
|
|
1073
|
+
operation: "start",
|
|
1074
|
+
userId: input.userId,
|
|
1075
|
+
identity,
|
|
1076
|
+
});
|
|
1077
|
+
if (Option.isSome(existing)) {
|
|
1078
|
+
return yield* oauthLinkConfirmationError("start", "OAuth provider account is already linked to this user");
|
|
1079
|
+
}
|
|
1080
|
+
const secret = input.secret ??
|
|
1081
|
+
(yield* dependencies.crypto
|
|
1082
|
+
.randomToken(yield* validateOAuthLinkConfirmationSecretBytes(dependencies.secretBytes ?? defaultOAuthLinkConfirmationSecretBytes))
|
|
1083
|
+
.pipe(Effect.map(Redacted.make), Effect.mapError((cause) => oauthLinkConfirmationError("start", "Failed to generate OAuth link confirmation secret", cause))));
|
|
1084
|
+
const issued = yield* dependencies.challenge
|
|
1085
|
+
.issue({
|
|
1086
|
+
type: oauthLinkConfirmationChallengeType,
|
|
1087
|
+
subject: input.userId,
|
|
1088
|
+
ttl: input.ttl ?? defaultOAuthLinkConfirmationTtl,
|
|
1089
|
+
secret,
|
|
1090
|
+
metadata: oauthLinkConfirmationMetadata({
|
|
1091
|
+
userId: input.userId,
|
|
1092
|
+
identity,
|
|
1093
|
+
reason,
|
|
1094
|
+
metadata: input.metadata,
|
|
1095
|
+
}),
|
|
1096
|
+
})
|
|
1097
|
+
.pipe(Effect.mapError((cause) => oauthLinkConfirmationError("start", "Failed to issue OAuth link confirmation challenge", cause)));
|
|
1098
|
+
return {
|
|
1099
|
+
challengeId: issued.id,
|
|
1100
|
+
userId: input.userId,
|
|
1101
|
+
identity,
|
|
1102
|
+
reason,
|
|
1103
|
+
secret,
|
|
1104
|
+
expiresAt: issued.expiresAt,
|
|
1105
|
+
...(input.metadata === undefined
|
|
1106
|
+
? {}
|
|
1107
|
+
: { metadata: metadataValue(input.metadata) }),
|
|
1108
|
+
};
|
|
1109
|
+
}),
|
|
1110
|
+
inspect: Effect.fn("auth.oauth_link_confirmation.inspect")(function* (input) {
|
|
1111
|
+
const inspected = yield* dependencies.challenge
|
|
1112
|
+
.inspect({
|
|
1113
|
+
challengeId: input.challengeId,
|
|
1114
|
+
type: oauthLinkConfirmationChallengeType,
|
|
1115
|
+
secret: input.secret,
|
|
1116
|
+
})
|
|
1117
|
+
.pipe(Effect.mapError((cause) => oauthLinkConfirmationError("inspect", "Invalid OAuth link confirmation challenge", cause)));
|
|
1118
|
+
const pending = yield* pendingOAuthLinkConfirmationFromChallenge(inspected, "inspect");
|
|
1119
|
+
yield* findOAuthLinkConfirmationExistingAccount(dependencies, {
|
|
1120
|
+
operation: "inspect",
|
|
1121
|
+
userId: pending.userId,
|
|
1122
|
+
identity: pending.identity,
|
|
1123
|
+
});
|
|
1124
|
+
return pending;
|
|
1125
|
+
}),
|
|
1126
|
+
confirm: Effect.fn("auth.oauth_link_confirmation.confirm")(function* (input) {
|
|
1127
|
+
const inspected = yield* dependencies.challenge
|
|
1128
|
+
.inspect({
|
|
1129
|
+
challengeId: input.challengeId,
|
|
1130
|
+
type: oauthLinkConfirmationChallengeType,
|
|
1131
|
+
secret: input.secret,
|
|
1132
|
+
})
|
|
1133
|
+
.pipe(Effect.mapError((cause) => oauthLinkConfirmationError("confirm", "Invalid OAuth link confirmation challenge", cause)));
|
|
1134
|
+
const pending = yield* pendingOAuthLinkConfirmationFromChallenge(inspected, "confirm");
|
|
1135
|
+
if (input.currentUserId !== undefined && input.currentUserId !== pending.userId) {
|
|
1136
|
+
return yield* oauthLinkConfirmationError("confirm", "OAuth link confirmation does not match current user");
|
|
1137
|
+
}
|
|
1138
|
+
if (input.expectedProviderId !== undefined &&
|
|
1139
|
+
input.expectedProviderId !== pending.identity.providerId) {
|
|
1140
|
+
return yield* oauthLinkConfirmationError("confirm", "OAuth link confirmation provider mismatch");
|
|
1141
|
+
}
|
|
1142
|
+
if (input.expectedProviderAccountId !== undefined &&
|
|
1143
|
+
input.expectedProviderAccountId !== pending.identity.providerAccountId) {
|
|
1144
|
+
return yield* oauthLinkConfirmationError("confirm", "OAuth link confirmation provider account mismatch");
|
|
1145
|
+
}
|
|
1146
|
+
const existing = yield* findOAuthLinkConfirmationExistingAccount(dependencies, {
|
|
1147
|
+
operation: "confirm",
|
|
1148
|
+
userId: pending.userId,
|
|
1149
|
+
identity: pending.identity,
|
|
1150
|
+
});
|
|
1151
|
+
yield* dependencies.challenge
|
|
1152
|
+
.verify({
|
|
1153
|
+
challengeId: input.challengeId,
|
|
1154
|
+
type: oauthLinkConfirmationChallengeType,
|
|
1155
|
+
secret: input.secret,
|
|
1156
|
+
})
|
|
1157
|
+
.pipe(Effect.mapError((cause) => oauthLinkConfirmationError("confirm", "Failed to consume OAuth link confirmation challenge", cause)));
|
|
1158
|
+
return confirmedOAuthLinkConfirmationFromPending({
|
|
1159
|
+
pending,
|
|
1160
|
+
...(Option.isSome(existing) ? { existingAccount: existing.value } : {}),
|
|
1161
|
+
});
|
|
1162
|
+
}),
|
|
1163
|
+
});
|
|
1164
|
+
export const startOAuthLinkConfirmation = (input) => OAuthLinkConfirmation.use((confirmation) => confirmation.start(input));
|
|
1165
|
+
export const inspectOAuthLinkConfirmation = (input) => OAuthLinkConfirmation.use((confirmation) => confirmation.inspect(input));
|
|
1166
|
+
export const confirmOAuthLinkConfirmation = (input) => OAuthLinkConfirmation.use((confirmation) => confirmation.confirm(input));
|
|
1167
|
+
const makeOAuthPkceWithCrypto = (dependencies, input) => Effect.gen(function* () {
|
|
1168
|
+
const verifier = input?.codeVerifier === undefined
|
|
1169
|
+
? yield* dependencies.crypto
|
|
1170
|
+
.randomToken(yield* validateByteLength(input?.verifierBytes ??
|
|
1171
|
+
dependencies.codeVerifierBytes ??
|
|
1172
|
+
defaultOAuthCodeVerifierBytes, "pkce", "OAuth code verifier"))
|
|
1173
|
+
.pipe(Effect.map(OAuthCodeVerifier), Effect.mapError((cause) => oauthStateError("pkce", "Failed to generate OAuth code verifier", cause)))
|
|
1174
|
+
: OAuthCodeVerifier(Redacted.value(input.codeVerifier));
|
|
1175
|
+
const codeChallenge = yield* dependencies.crypto
|
|
1176
|
+
.digestSha256({ data: verifier })
|
|
1177
|
+
.pipe(Effect.map(OAuthCodeChallenge), Effect.mapError((cause) => oauthStateError("pkce", "Failed to calculate OAuth PKCE challenge", cause)));
|
|
1178
|
+
return {
|
|
1179
|
+
codeVerifier: Redacted.make(verifier),
|
|
1180
|
+
codeChallenge,
|
|
1181
|
+
codeChallengeMethod: "S256",
|
|
1182
|
+
};
|
|
1183
|
+
});
|
|
1184
|
+
export const makeOAuthPkce = (input) => Effect.gen(function* () {
|
|
1185
|
+
const crypto = yield* Crypto;
|
|
1186
|
+
return yield* makeOAuthPkceWithCrypto({ crypto }, input);
|
|
1187
|
+
});
|
|
1188
|
+
const appendParams = (url, params) => {
|
|
1189
|
+
if (params === undefined) {
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
for (const [key, value] of Object.entries(params)) {
|
|
1193
|
+
if (value !== undefined) {
|
|
1194
|
+
url.searchParams.set(key, value);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
const makeAuthorizationUrl = (input) => Effect.try({
|
|
1199
|
+
try: () => {
|
|
1200
|
+
const url = new URL(input.provider.authorizationEndpoint);
|
|
1201
|
+
appendParams(url, input.provider.authorizationParams);
|
|
1202
|
+
appendParams(url, input.params);
|
|
1203
|
+
appendParams(url, {
|
|
1204
|
+
response_type: "code",
|
|
1205
|
+
client_id: input.provider.clientId,
|
|
1206
|
+
redirect_uri: input.redirectUri,
|
|
1207
|
+
scope: input.scopes.length === 0 ? undefined : input.scopes.join(" "),
|
|
1208
|
+
state: input.state,
|
|
1209
|
+
code_challenge: input.pkce.codeChallenge,
|
|
1210
|
+
code_challenge_method: input.pkce.codeChallengeMethod,
|
|
1211
|
+
nonce: input.nonce,
|
|
1212
|
+
});
|
|
1213
|
+
return url.toString();
|
|
1214
|
+
},
|
|
1215
|
+
catch: (cause) => oauthStateError("authorization-url", "Failed to build OAuth authorization URL", cause),
|
|
1216
|
+
});
|
|
1217
|
+
export const makeOAuthState = (dependencies) => OAuthState.make({
|
|
1218
|
+
start: Effect.fn("auth.oauth_state.start")(function* (input) {
|
|
1219
|
+
const stateSecretBytes = yield* validateByteLength(dependencies.stateSecretBytes ?? defaultOAuthStateSecretBytes, "start", "OAuth state secret");
|
|
1220
|
+
const stateSecret = yield* dependencies.crypto.randomToken(stateSecretBytes).pipe(Effect.map(Redacted.make), Effect.mapError((cause) => oauthStateError("start", "Failed to generate OAuth state secret", cause)));
|
|
1221
|
+
const pkce = yield* makeOAuthPkceWithCrypto(dependencies, {
|
|
1222
|
+
codeVerifier: input.codeVerifier,
|
|
1223
|
+
verifierBytes: dependencies.codeVerifierBytes,
|
|
1224
|
+
});
|
|
1225
|
+
const includeNonce = input.includeNonce ?? input.provider.issuer !== undefined;
|
|
1226
|
+
const nonce = input.nonce === undefined && !includeNonce
|
|
1227
|
+
? undefined
|
|
1228
|
+
: input.nonce === undefined
|
|
1229
|
+
? yield* dependencies.crypto
|
|
1230
|
+
.randomToken(dependencies.nonceBytes ?? defaultOAuthNonceBytes)
|
|
1231
|
+
.pipe(Effect.map(OAuthNonce), Effect.mapError((cause) => oauthStateError("start", "Failed to generate OAuth nonce", cause)))
|
|
1232
|
+
: OAuthNonce(input.nonce);
|
|
1233
|
+
const redirectUri = input.redirectUri ?? input.provider.redirectUri;
|
|
1234
|
+
const scopes = input.scopes ?? input.provider.scopes ?? [];
|
|
1235
|
+
const issued = yield* dependencies.challenge
|
|
1236
|
+
.issue({
|
|
1237
|
+
type: "oauth-state",
|
|
1238
|
+
subject: input.provider.id,
|
|
1239
|
+
ttl: input.ttl ?? defaultOAuthStateTtl,
|
|
1240
|
+
secret: stateSecret,
|
|
1241
|
+
metadata: {
|
|
1242
|
+
providerId: input.provider.id,
|
|
1243
|
+
redirectUri,
|
|
1244
|
+
scopes,
|
|
1245
|
+
codeChallenge: pkce.codeChallenge,
|
|
1246
|
+
...(nonce === undefined ? {} : { nonce }),
|
|
1247
|
+
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
|
1248
|
+
},
|
|
1249
|
+
})
|
|
1250
|
+
.pipe(Effect.mapError((cause) => oauthStateError("start", "Failed to issue OAuth state challenge", cause)));
|
|
1251
|
+
const state = encodeOAuthState({ challengeId: issued.id, secret: stateSecret });
|
|
1252
|
+
const authorizationUrl = yield* makeAuthorizationUrl({
|
|
1253
|
+
provider: input.provider,
|
|
1254
|
+
redirectUri,
|
|
1255
|
+
scopes,
|
|
1256
|
+
params: input.authorizationParams,
|
|
1257
|
+
state,
|
|
1258
|
+
pkce,
|
|
1259
|
+
nonce,
|
|
1260
|
+
});
|
|
1261
|
+
return {
|
|
1262
|
+
providerId: input.provider.id,
|
|
1263
|
+
authorizationUrl,
|
|
1264
|
+
state,
|
|
1265
|
+
challengeId: issued.id,
|
|
1266
|
+
expiresAt: issued.expiresAt,
|
|
1267
|
+
codeVerifier: pkce.codeVerifier,
|
|
1268
|
+
codeChallenge: pkce.codeChallenge,
|
|
1269
|
+
codeChallengeMethod: pkce.codeChallengeMethod,
|
|
1270
|
+
...(nonce === undefined ? {} : { nonce }),
|
|
1271
|
+
redirectUri,
|
|
1272
|
+
scopes,
|
|
1273
|
+
};
|
|
1274
|
+
}),
|
|
1275
|
+
verify: Effect.fn("auth.oauth_state.verify")(function* (input) {
|
|
1276
|
+
const parsed = yield* parseOAuthState(input.state);
|
|
1277
|
+
const verified = yield* dependencies.challenge
|
|
1278
|
+
.verify({
|
|
1279
|
+
challengeId: parsed.challengeId,
|
|
1280
|
+
type: "oauth-state",
|
|
1281
|
+
secret: parsed.secret,
|
|
1282
|
+
})
|
|
1283
|
+
.pipe(Effect.mapError((cause) => oauthStateError("verify", "Failed to verify OAuth state challenge", cause)));
|
|
1284
|
+
const metadata = verified.metadata ?? {};
|
|
1285
|
+
const appMetadata = metadataValue(typeof metadata.metadata === "object" && metadata.metadata !== null
|
|
1286
|
+
? metadata.metadata
|
|
1287
|
+
: undefined);
|
|
1288
|
+
const codeChallenge = stringFromUnknown(metadata.codeChallenge);
|
|
1289
|
+
const nonce = stringFromUnknown(metadata.nonce);
|
|
1290
|
+
return {
|
|
1291
|
+
challengeId: parsed.challengeId,
|
|
1292
|
+
providerId: OAuthProviderId(verified.subject),
|
|
1293
|
+
redirectUri: stringFromUnknown(metadata.redirectUri),
|
|
1294
|
+
scopes: stringArrayFromUnknown(metadata.scopes),
|
|
1295
|
+
...(codeChallenge === undefined
|
|
1296
|
+
? {}
|
|
1297
|
+
: { codeChallenge: OAuthCodeChallenge(codeChallenge) }),
|
|
1298
|
+
...(nonce === undefined ? {} : { nonce: OAuthNonce(nonce) }),
|
|
1299
|
+
...(appMetadata === undefined ? {} : { metadata: appMetadata }),
|
|
1300
|
+
};
|
|
1301
|
+
}),
|
|
1302
|
+
});
|
|
1303
|
+
export const OAuthStateLive = Layer.effect(OAuthState)(Effect.gen(function* () {
|
|
1304
|
+
const crypto = yield* Crypto;
|
|
1305
|
+
const challenge = yield* Challenge;
|
|
1306
|
+
return makeOAuthState({ crypto, challenge });
|
|
1307
|
+
}));
|
|
1308
|
+
export const makeOAuthProviders = (providers) => {
|
|
1309
|
+
const providersById = new Map(providers.map((provider) => [provider.id, cloneProviderConfig(provider)]));
|
|
1310
|
+
return OAuthProviders.make({
|
|
1311
|
+
get: (id) => Effect.sync(() => {
|
|
1312
|
+
const provider = providersById.get(id);
|
|
1313
|
+
return provider === undefined
|
|
1314
|
+
? Option.none()
|
|
1315
|
+
: Option.some(cloneProviderConfig(provider));
|
|
1316
|
+
}),
|
|
1317
|
+
});
|
|
1318
|
+
};
|
|
1319
|
+
export const OAuthProvidersLive = (providers) => Layer.succeed(OAuthProviders)(makeOAuthProviders(providers));
|
|
1320
|
+
export const OidcIdTokenVerifierLive = Layer.effect(OidcIdTokenVerifier)(Effect.gen(function* () {
|
|
1321
|
+
const jwtVerifier = yield* JwtVerifier;
|
|
1322
|
+
return makeOidcIdTokenVerifier({ jwtVerifier });
|
|
1323
|
+
}));
|
|
1324
|
+
export const OAuthOidcProfileNormalizerLive = Layer.succeed(OAuthProfileNormalizer, makeOidcOAuthProfileNormalizer());
|
|
1325
|
+
export const OAuthAccountLinkingLive = Layer.effect(OAuthAccountLinking)(Effect.gen(function* () {
|
|
1326
|
+
const accounts = yield* OAuthAccountStore;
|
|
1327
|
+
return makeOAuthAccountLinkingPolicy({ accounts });
|
|
1328
|
+
}));
|
|
1329
|
+
export const OAuthAccountUnlinkingLive = Layer.effect(OAuthAccountUnlinking)(Effect.gen(function* () {
|
|
1330
|
+
const accounts = yield* OAuthAccountStore;
|
|
1331
|
+
return makeOAuthAccountUnlinkingPolicy({ accounts });
|
|
1332
|
+
}));
|
|
1333
|
+
export const OAuthLinkConfirmationLive = Layer.effect(OAuthLinkConfirmation)(Effect.gen(function* () {
|
|
1334
|
+
const challenge = yield* Challenge;
|
|
1335
|
+
const accounts = yield* OAuthAccountStore;
|
|
1336
|
+
const crypto = yield* Crypto;
|
|
1337
|
+
return makeOAuthLinkConfirmation({ challenge, accounts, crypto });
|
|
1338
|
+
}));
|
|
1339
|
+
const accountKey = (input) => `${input.providerId}:${input.providerAccountId}`;
|
|
1340
|
+
const cloneOAuthAccountRecord = (record) => ({
|
|
1341
|
+
...record,
|
|
1342
|
+
...(record.metadata === undefined ? {} : { metadata: metadataValue(record.metadata) }),
|
|
1343
|
+
});
|
|
1344
|
+
const includeAccount = (record, includeUnlinked) => includeUnlinked === true || record.unlinkedAt === undefined;
|
|
1345
|
+
export const makeOAuthAccountStoreMemory = () => {
|
|
1346
|
+
const accountsById = new Map();
|
|
1347
|
+
const idByProviderAccount = new Map();
|
|
1348
|
+
return OAuthAccountStore.make({
|
|
1349
|
+
insert: (row) => Effect.sync(() => {
|
|
1350
|
+
const cloned = cloneOAuthAccountRecord(row);
|
|
1351
|
+
accountsById.set(row.id, cloned);
|
|
1352
|
+
idByProviderAccount.set(accountKey(row), row.id);
|
|
1353
|
+
}),
|
|
1354
|
+
findById: (id) => Effect.sync(() => {
|
|
1355
|
+
const row = accountsById.get(id);
|
|
1356
|
+
return row === undefined ? Option.none() : Option.some(cloneOAuthAccountRecord(row));
|
|
1357
|
+
}),
|
|
1358
|
+
findByProviderAccount: (input) => Effect.sync(() => {
|
|
1359
|
+
const id = idByProviderAccount.get(accountKey(input));
|
|
1360
|
+
const row = id === undefined ? undefined : accountsById.get(id);
|
|
1361
|
+
return row === undefined || !includeAccount(row, input.includeUnlinked)
|
|
1362
|
+
? Option.none()
|
|
1363
|
+
: Option.some(cloneOAuthAccountRecord(row));
|
|
1364
|
+
}),
|
|
1365
|
+
listByUser: (input) => Effect.sync(() => [...accountsById.values()]
|
|
1366
|
+
.filter((row) => row.userId === input.userId && includeAccount(row, input.includeUnlinked))
|
|
1367
|
+
.map(cloneOAuthAccountRecord)),
|
|
1368
|
+
unlink: (input) => Effect.sync(() => {
|
|
1369
|
+
const id = idByProviderAccount.get(accountKey(input));
|
|
1370
|
+
const row = id === undefined ? undefined : accountsById.get(id);
|
|
1371
|
+
if (row === undefined) {
|
|
1372
|
+
return Option.none();
|
|
1373
|
+
}
|
|
1374
|
+
const updated = {
|
|
1375
|
+
...row,
|
|
1376
|
+
unlinkedAt: input.unlinkedAt,
|
|
1377
|
+
metadata: input.reason === undefined
|
|
1378
|
+
? row.metadata
|
|
1379
|
+
: { ...row.metadata, unlinkReason: input.reason },
|
|
1380
|
+
};
|
|
1381
|
+
accountsById.set(row.id, updated);
|
|
1382
|
+
return Option.some(cloneOAuthAccountRecord(updated));
|
|
1383
|
+
}),
|
|
1384
|
+
});
|
|
1385
|
+
};
|
|
1386
|
+
export const OAuthAccountStoreMemoryLive = Layer.sync(OAuthAccountStore)(makeOAuthAccountStoreMemory);
|
|
1387
|
+
//# sourceMappingURL=OAuth.js.map
|