1import * as Neon from "@distilled.cloud/neon";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import { OwnedBySomeoneElse, Unowned } from "../AdoptPolicy.ts";
5import { isResolved } from "../Diff.ts";
6import * as Provider from "../Provider.ts";
7import { Resource } from "../Resource.ts";
8import {
9 authPlanScope,
10 authRequest,
11 InvalidManagedAuth,
12 removedAuthSettings,
13} from "./Auth.ts";
14import type { Input } from "../Input.ts";
15import type { Providers } from "./Providers.ts";
16
17export interface AuthOAuthProviderProps {
18 /** Managed Auth integration; its scope is inherited without owning the parent. */
19 auth: { projectId: string; branchId: string };
20 /** Supported OAuth provider identity. Changing it replaces this child. */
21 provider: Neon.NeonAuthOauthProviderId;
22 /** OAuth client ID. Omit for Neon-managed development credentials. */
23 clientId?: string;
24 /** OAuth secret, never included in resource outputs. */
25 clientSecret?: Redacted.Redacted<string>;
26 /** Microsoft tenant ID; write-only in the current read schema. */
27 microsoftTenantId?: string;
28}
29
30export interface AuthOAuthProviderAttributes {
31 /** Neon project identity. */
32 projectId: string;
33 /** Owning branch identity. */
34 branchId: string;
35 /** OAuth provider identity. */
36 provider: Neon.NeonAuthOauthProviderId;
37 /** Whether Neon-managed development or application credentials are active. */
38 type: Neon.NeonAuthOauthProviderType;
39 /** Public OAuth client identifier, when configured. */
40 clientId: string | undefined;
41}
42
43export interface AuthOAuthProvider extends Resource<
44 "Neon.AuthOAuthProvider",
45 AuthOAuthProviderProps,
46 AuthOAuthProviderAttributes,
47 never,
48 Providers
49> {}
50
51/**
52 * Independently own one managed Auth OAuth provider. Shared credentials are for
53 * development only. Existing providers, including inherited providers, require
54 * explicit adoption. Removing managed credential fields is rejected; replace the
55 * child explicitly to switch back to development credentials.
56 *
57 * ### Configure an OAuth application
58 * **Example:** GitHub sign-in
59 * ```typescript
60 * yield* Neon.AuthOAuthProvider("GitHub", {
61 * auth, provider: "github", clientId: "application-id",
62 * clientSecret: yield* Config.Redacted("GITHUB_CLIENT_SECRET"),
63 * });
64 * ```
65 *
66 * @resource
67 * @product Auth
68 */
69export const AuthOAuthProvider = Resource<AuthOAuthProvider>(
70 "Neon.AuthOAuthProvider",
71);
72
73const observe = (
74 scope: { projectId: string; branchId: string },
75 provider: Neon.NeonAuthOauthProviderId,
76) =>
77 Neon.listBranchNeonAuthOauthProviders(authRequest(scope)).pipe(
78 Effect.map((response) =>
79 response.providers.find((item) => item.id === provider),
80 ),
81 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
82 );
83
84const attributes = (
85 scope: { projectId: string; branchId: string },
86 value: Neon.NeonAuthOauthProvider,
87): AuthOAuthProviderAttributes => ({
88 projectId: scope.projectId,
89 branchId: scope.branchId,
90 provider: value.id,
91 type: value.type,
92 clientId: value.client_id,
93});
94
95const validateOAuthRemoval = (
96 olds: AuthOAuthProviderProps | undefined,
97 news: Input<AuthOAuthProviderProps>,
98) => {
99 const removed = removedAuthSettings(olds, news, [
100 "clientId",
101 "clientSecret",
102 "microsoftTenantId",
103 ]);
104 return removed.length === 0
105 ? Effect.void
106 : Effect.fail(
107 new InvalidManagedAuth({
108 message: `Cannot remove managed OAuth settings: ${removed.join(", ")}; replace the OAuth child explicitly`,
109 }),
110 );
111};
112
113export const AuthOAuthProviderProvider = () =>
114 Provider.succeed(AuthOAuthProvider, {
115 stables: ["projectId", "branchId", "provider"],
116 diff: Effect.fn(function* ({ olds, news, output }) {
117 yield* validateOAuthRemoval(olds, news);
118 const scope =
119 "auth" in news
120 ? yield* authPlanScope({ branch: news.auth })
121 : undefined;
122 if (
123 output &&
124 (!scope ||
125 scope.projectId !== output.projectId ||
126 scope.branchId !== output.branchId ||
127 !("provider" in news) ||
128 news.provider !== output.provider)
129 )
130 return { action: "replace" } as const;
131 if (!isResolved(news)) return;
132 if (
133 news.auth.projectId !== olds.auth.projectId ||
134 news.auth.branchId !== olds.auth.branchId ||
135 news.provider !== olds.provider
136 ) {
137 return { action: "replace" } as const;
138 }
139 if (output) {
140 const current = yield* observe(news.auth, news.provider);
141 if (
142 !current ||
143 (news.clientId !== undefined && current.client_id !== news.clientId)
144 )
145 return { action: "update" } as const;
146 }
147 }),
148 read: Effect.fn(function* ({ olds, output }) {
149 const scope = output ?? olds.auth;
150 const current = yield* observe(scope, output?.provider ?? olds.provider);
151 if (!current) return undefined;
152 const attrs = attributes(scope, current);
153 return output ? attrs : Unowned(attrs);
154 }),
155 reconcile: Effect.fn(function* ({ news, output, olds }) {
156 yield* validateOAuthRemoval(olds, news);
157 if (
158 output &&
159 (news.auth.projectId !== output.projectId ||
160 news.auth.branchId !== output.branchId ||
161 news.provider !== output.provider)
162 )
163 return yield* new InvalidManagedAuth({
164 message:
165 "OAuth identity changed without replacement; refusing to mutate either provider",
166 });
167 const request = authRequest(news.auth);
168 let current = yield* observe(news.auth, news.provider);
169 if (current && !output)
170 return yield* new OwnedBySomeoneElse({
171 message: "Existing OAuth provider requires explicit adoption",
172 resourceType: "Neon.AuthOAuthProvider",
173 });
174 if (!current) {
175 yield* Neon.addBranchNeonAuthOauthProvider({
176 ...request,
177 id: news.provider,
178 client_id: news.clientId,
179 client_secret: news.clientSecret,
180 microsoft_tenant_id: news.microsoftTenantId,
181 }).pipe(
182 Effect.catchTag("Conflict", () =>
183 Effect.fail(
184 new OwnedBySomeoneElse({
185 message:
186 "OAuth provider appeared during creation; explicit adoption is required",
187 resourceType: "Neon.AuthOAuthProvider",
188 }),
189 ),
190 ),
191 );
192 current = yield* observe(news.auth, news.provider);
193 }
194 const secretMatches =
195 news.clientSecret === undefined ||
196 (current?.client_secret !== undefined &&
197 (Redacted.isRedacted(current.client_secret)
198 ? Redacted.value(current.client_secret)
199 : current.client_secret) === Redacted.value(news.clientSecret));
200 // The API may mask secrets and does not expose the Microsoft tenant on GET.
201 if (
202 (news.clientId !== undefined && news.clientId !== current?.client_id) ||
203 !secretMatches ||
204 news.microsoftTenantId !== undefined
205 ) {
206 yield* Neon.updateBranchNeonAuthOauthProvider({
207 ...request,
208 oauth_provider_id: news.provider,
209 client_id: news.clientId,
210 client_secret: news.clientSecret,
211 microsoft_tenant_id: news.microsoftTenantId,
212 });
213 }
214 const result = yield* Neon.listBranchNeonAuthOauthProviders(request);
215 const provider = result.providers.find(
216 (item) => item.id === news.provider,
217 );
218 if (!provider)
219 return yield* Effect.fail(
220 new Error("OAuth provider was not visible after reconciliation"),
221 );
222 return attributes(news.auth, provider);
223 }),
224 delete: Effect.fn(function* ({ output }) {
225 if (yield* observe(output, output.provider)) {
226 yield* Neon.deleteBranchNeonAuthOauthProvider({
227 ...authRequest(output),
228 oauth_provider_id: output.provider,
229 }).pipe(Effect.catchTag("NotFound", () => Effect.void));
230 }
231 }),
232 });