@@ -17,23 +17,47 @@ import {
1717} from "~/components/primitives/Table" ;
1818import { env } from "~/env.server" ;
1919import { logger } from "~/services/logger.server" ;
20- import { oidcSigningKeyService } from "~/services/oidc/instance.server" ;
21- import { OidcSigningKeyError } from "~/services/oidc/signingKeyService.server" ;
20+ import {
21+ externalOidcSigningService ,
22+ internalRunSigningService ,
23+ signingServiceFor ,
24+ } from "~/services/oidc/instance.server" ;
25+ import {
26+ OidcSigningKeyError ,
27+ type OidcSigningStatus ,
28+ } from "~/services/oidc/signingKeyService.server" ;
2229import { dashboardAction , dashboardLoader } from "~/services/routeBuilders/dashboardBuilder" ;
2330import { requestUrl } from "~/utils/requestUrl.server" ;
2431
32+ const PurposeSchema = z . enum ( [ "EXTERNAL_OIDC" , "INTERNAL_RUN" ] ) ;
33+ type Purpose = z . infer < typeof PurposeSchema > ;
34+
2535const MutationSchema = z . discriminatedUnion ( "intent" , [
26- z . object ( { intent : z . literal ( "generate" ) } ) ,
27- z . object ( { intent : z . literal ( "activate" ) , keyId : z . string ( ) . min ( 1 ) } ) ,
28- z . object ( { intent : z . literal ( "disable" ) } ) ,
36+ z . object ( { intent : z . literal ( "generate" ) , purpose : PurposeSchema } ) ,
37+ z . object ( { intent : z . literal ( "activate" ) , purpose : PurposeSchema , keyId : z . string ( ) . min ( 1 ) } ) ,
38+ z . object ( { intent : z . literal ( "disable" ) , purpose : PurposeSchema } ) ,
2939] ) ;
3040
3141type MutationResult = { success ?: boolean ; error ?: string } ;
3242
43+ const ISSUER_SETTING : Record < Purpose , string > = {
44+ EXTERNAL_OIDC : "OIDC_ISSUER_URL" ,
45+ INTERNAL_RUN : "RUN_TOKEN_ISSUER_URL" ,
46+ } ;
47+
3348export const loader = dashboardLoader ( { authorization : { requireSuper : true } } , async ( ) => {
34- const status = await oidcSigningKeyService . getSigningStatus ( ) ;
49+ const [ external , internal ] = await Promise . all ( [
50+ externalOidcSigningService . getSigningStatus ( ) ,
51+ internalRunSigningService . getSigningStatus ( ) ,
52+ ] ) ;
3553 return typedjson (
36- { issuer : env . OIDC_ISSUER_URL , status, now : Date . now ( ) } ,
54+ {
55+ keySets : [
56+ { purpose : "EXTERNAL_OIDC" as const , issuer : env . OIDC_ISSUER_URL , status : external } ,
57+ { purpose : "INTERNAL_RUN" as const , issuer : env . RUN_TOKEN_ISSUER_URL , status : internal } ,
58+ ] ,
59+ now : Date . now ( ) ,
60+ } ,
3761 {
3862 headers : { "Cache-Control" : "no-store" } ,
3963 }
@@ -53,28 +77,29 @@ export const action = dashboardAction(
5377 if ( request . headers . get ( "Origin" ) !== requestUrl ( request ) . origin ) {
5478 return typedjson ( { error : "Invalid request origin" } , { status : 403 } ) ;
5579 }
56- if ( ! env . OIDC_ISSUER_URL ) {
57- return typedjson (
58- { error : "Configure OIDC_ISSUER_URL before managing keys" } ,
59- { status : 400 }
60- ) ;
61- }
62-
6380 const result = MutationSchema . safeParse ( Object . fromEntries ( await request . formData ( ) ) ) ;
6481 if ( ! result . success ) {
6582 return typedjson ( { error : "Invalid key operation" } , { status : 400 } ) ;
6683 }
6784
85+ const service = signingServiceFor ( result . data . purpose ) ;
86+ if ( ! service . issuer ) {
87+ return typedjson (
88+ { error : `Configure ${ ISSUER_SETTING [ result . data . purpose ] } before managing these keys` } ,
89+ { status : 400 }
90+ ) ;
91+ }
92+
6893 try {
6994 switch ( result . data . intent ) {
7095 case "generate" :
71- await oidcSigningKeyService . generateStagedKey ( user . id ) ;
96+ await service . generateStagedKey ( user . id ) ;
7297 break ;
7398 case "activate" :
74- await oidcSigningKeyService . activateKey ( result . data . keyId , user . id ) ;
99+ await service . activateKey ( result . data . keyId , user . id ) ;
75100 break ;
76101 case "disable" :
77- await oidcSigningKeyService . disableSigning ( user . id ) ;
102+ await service . disableSigning ( user . id ) ;
78103 break ;
79104 }
80105 return typedjson ( { success : true } ) ;
@@ -84,6 +109,7 @@ export const action = dashboardAction(
84109 }
85110 logger . error ( "OIDC signing key operation failed" , {
86111 operation : result . data . intent ,
112+ purpose : result . data . purpose ,
87113 userId : user . id ,
88114 } ) ;
89115 return typedjson (
@@ -97,58 +123,100 @@ export const action = dashboardAction(
97123) ;
98124
99125export default function AdminOidcRoute ( ) {
100- const { issuer, status, now } = useTypedLoaderData < typeof loader > ( ) ;
101- const generateFetcher = useFetcher < MutationResult > ( ) ;
126+ const { keySets, now } = useTypedLoaderData < typeof loader > ( ) ;
102127 const revalidator = useRevalidator ( ) ;
128+
129+ return (
130+ < main className = "flex h-full flex-col gap-6 overflow-y-auto px-4 pb-4" >
131+ < div className = "flex items-center justify-between gap-4" >
132+ < Paragraph className = "text-text-bright" > OIDC signing keys</ Paragraph >
133+ < Button
134+ variant = "tertiary/small"
135+ onClick = { ( ) => revalidator . revalidate ( ) }
136+ disabled = { revalidator . state !== "idle" }
137+ >
138+ Refresh
139+ </ Button >
140+ </ div >
141+ { keySets . map ( ( keySet ) => (
142+ < KeySet key = { keySet . purpose } { ...keySet } now = { now } />
143+ ) ) }
144+ < Paragraph variant = "small" >
145+ Keys use RS256. Private keys are encrypted and are never displayed. Each key set rotates
146+ independently. Refresh after the activation-ready time to enable activation.
147+ </ Paragraph >
148+ </ main >
149+ ) ;
150+ }
151+
152+ const KEY_SET_COPY : Record < Purpose , { title : string ; description : string } > = {
153+ EXTERNAL_OIDC : {
154+ title : "External OIDC" ,
155+ description :
156+ "Signs tokens for external relying parties such as cloud providers. Public keys are published at the issuer's JWKS URL." ,
157+ } ,
158+ INTERNAL_RUN : {
159+ title : "Internal run tokens" ,
160+ description :
161+ "Signs per-attempt run tokens accepted by the Trigger API. Public keys are never published." ,
162+ } ,
163+ } ;
164+
165+ function KeySet ( {
166+ purpose,
167+ issuer,
168+ status,
169+ now,
170+ } : {
171+ purpose : Purpose ;
172+ issuer : string | undefined ;
173+ status : OidcSigningStatus ;
174+ now : number ;
175+ } ) {
176+ const generateFetcher = useFetcher < MutationResult > ( ) ;
177+ const copy = KEY_SET_COPY [ purpose ] ;
103178 const keys = [
104179 ...( status . activeKey ? [ status . activeKey ] : [ ] ) ,
105180 ...( status . stagedKey ? [ status . stagedKey ] : [ ] ) ,
106181 ...status . retiredKeys ,
107182 ] ;
108183
109184 return (
110- < main className = "flex h-full flex-col gap-4 overflow-y-auto px-4 pb -4" >
185+ < section className = "flex flex-col gap-4" >
111186 < div className = "flex items-center justify-between gap-4" >
112187 < div className = "space-y-1" >
113- < Paragraph className = "text-text-bright" > OIDC signing keys</ Paragraph >
188+ < Paragraph className = "text-text-bright" > { copy . title } </ Paragraph >
189+ < Paragraph variant = "small" > { copy . description } </ Paragraph >
114190 < Paragraph variant = "small" >
115191 { issuer
116192 ? `Issuer: ${ issuer } `
117- : "OIDC is disabled . Configure OIDC_ISSUER_URL to enable it." }
193+ : `Disabled . Configure ${ ISSUER_SETTING [ purpose ] } to enable it.` }
118194 </ Paragraph >
119195 </ div >
120- < div className = "flex gap-2" >
196+ < generateFetcher . Form method = "post" >
197+ < input type = "hidden" name = "intent" value = "generate" />
198+ < input type = "hidden" name = "purpose" value = { purpose } />
121199 < Button
122- variant = "tertiary/small "
123- onClick = { ( ) => revalidator . revalidate ( ) }
124- disabled = { revalidator . state !== "idle" }
200+ type = "submit "
201+ variant = "primary/small"
202+ disabled = { ! issuer || ! ! status . stagedKey || generateFetcher . state !== "idle" }
125203 >
126- Refresh
204+ Generate key
127205 </ Button >
128- < generateFetcher . Form method = "post" >
129- < input type = "hidden" name = "intent" value = "generate" />
130- < Button
131- type = "submit"
132- variant = "primary/small"
133- disabled = { ! issuer || ! ! status . stagedKey || generateFetcher . state !== "idle" }
134- >
135- Generate key
136- </ Button >
137- </ generateFetcher . Form >
138- </ div >
206+ </ generateFetcher . Form >
139207 </ div >
140208
141209 { generateFetcher . data ?. error && (
142210 < Callout variant = "error" > { generateFetcher . data . error } </ Callout >
143211 ) }
144212 < Callout variant = { issuer && status . activeKey ? "info" : "warning" } >
145213 { ! issuer
146- ? " Token signing is disabled because OIDC_ISSUER_URL is not configured. Stored signing keys are unchanged."
214+ ? ` Token signing is disabled because ${ ISSUER_SETTING [ purpose ] } is not configured. Stored signing keys are unchanged.`
147215 : status . activeKey
148- ? "One key signs new tokens. Staged keys are published before activation, and retired public keys remain available until existing tokens expire."
216+ ? "One key signs new tokens. Staged keys are trusted before activation, and retired public keys remain trusted until existing tokens expire."
149217 : "No active signing key. Token signing is disabled. Retained public keys can still verify existing tokens." }
150218 </ Callout >
151- { issuer && (
219+ { issuer && purpose === "EXTERNAL_OIDC" && (
152220 < div className = "flex gap-4 text-sm text-text-link" >
153221 < a href = { `${ issuer } /.well-known/openid-configuration` } target = "_blank" rel = "noreferrer" >
154222 Discovery document
@@ -165,7 +233,7 @@ export default function AdminOidcRoute() {
165233 < TableHeaderCell > Status</ TableHeaderCell >
166234 < TableHeaderCell > Generated</ TableHeaderCell >
167235 < TableHeaderCell > Activation ready</ TableHeaderCell >
168- < TableHeaderCell > Public until</ TableHeaderCell >
236+ < TableHeaderCell > Trusted until</ TableHeaderCell >
169237 < TableHeaderCell > Actions</ TableHeaderCell >
170238 </ TableRow >
171239 </ TableHeader >
@@ -192,18 +260,20 @@ export default function AdminOidcRoute() {
192260 { key . status === "STAGED" && (
193261 < KeyMutation
194262 intent = "activate"
263+ purpose = { purpose }
195264 keyId = { key . id }
196265 disabled = { ! issuer || key . readyAt . getTime ( ) > now }
197266 label = "Activate"
198- confirmation = "Activate this key? It will sign new tokens. Any current signing key will be retired, but its public key will remain available for existing tokens."
267+ confirmation = "Activate this key? It will sign new tokens. Any current signing key in this set will be retired, but its public key will remain trusted for existing tokens."
199268 />
200269 ) }
201270 { key . status === "ACTIVE" && (
202271 < KeyMutation
203272 intent = "disable"
273+ purpose = { purpose }
204274 disabled = { ! issuer }
205275 label = "Disable signing"
206- confirmation = "Disable token signing? No new tokens can be signed until another key is activated. Existing tokens remain valid until expiry."
276+ confirmation = "Disable token signing for this key set ? No new tokens can be signed until another key is activated. Existing tokens remain valid until expiry."
207277 />
208278 ) }
209279 </ TableCell >
@@ -212,22 +282,20 @@ export default function AdminOidcRoute() {
212282 ) }
213283 </ TableBody >
214284 </ Table >
215- < Paragraph variant = "small" >
216- Keys use RS256. Private keys are encrypted and are never displayed. Refresh after the
217- activation-ready time to enable activation.
218- </ Paragraph >
219- </ main >
285+ </ section >
220286 ) ;
221287}
222288
223289function KeyMutation ( {
224290 intent,
291+ purpose,
225292 keyId,
226293 disabled,
227294 label,
228295 confirmation,
229296} : {
230297 intent : "activate" | "disable" ;
298+ purpose : Purpose ;
231299 keyId ?: string ;
232300 disabled : boolean ;
233301 label : string ;
@@ -256,6 +324,7 @@ function KeyMutation({
256324 </ Button >
257325 < fetcher . Form method = "post" onSubmit = { ( ) => setOpen ( false ) } >
258326 < input type = "hidden" name = "intent" value = { intent } />
327+ < input type = "hidden" name = "purpose" value = { purpose } />
259328 { keyId && < input type = "hidden" name = "keyId" value = { keyId } /> }
260329 < Button
261330 type = "submit"
0 commit comments