Skip to content

Commit 9396c0d

Browse files
Introduce @authenticated directive in composition (#2644)
With this change, users can now compose `@authenticated` directive applications from their subgraphs into a supergraph. This addition will support a future version of Apollo Router that enables authenticated access to specific types and fields via directive applications. Since the implementation of `@authenticated` is strictly a composition and execution concern, there's no change to the query planner with this work. The execution work is well under way in Apollo Router (and won't be built at all for Gateway). So as far as this repo is concerned, only composition is concerned with `@authenticated`.
1 parent aac2893 commit 9396c0d

File tree

9 files changed

+368
-9
lines changed

9 files changed

+368
-9
lines changed

.changeset/gold-schools-flash.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
"@apollo/composition": minor
3+
"@apollo/federation-internals": minor
4+
"@apollo/subgraph": minor
5+
"@apollo/gateway": minor
6+
---
7+
8+
Introduce the new `@authenticated` directive for composition
9+
10+
> Note that this directive will only be _fully_ supported by the Apollo Router as a GraphOS Enterprise feature at runtime. Also note that _composition_ of valid `@authenticated` directive applications will succeed, but the resulting supergraph will not be _executable_ by the Gateway or an Apollo Router which doesn't have the GraphOS Enterprise entitlement.
11+
12+
Users may now compose `@authenticated` applications from their subgraphs into a supergraph. This addition will support a future version of Apollo Router that enables authenticated access to specific types and fields via directive applications.
13+
14+
The directive is defined as follows:
15+
16+
```graphql
17+
directive @authenticated on
18+
| FIELD_DEFINITION
19+
| OBJECT
20+
| INTERFACE
21+
| SCALAR
22+
| ENUM
23+
```
24+
25+
In order to compose your `@authenticated` usages, you must update your subgraph's federation spec version to v2.5 and add the `@authenticated` import to your existing imports like so:
26+
```graphql
27+
@link(url: "https://specs.apollo.dev/federation/v2.5", import: [..., "@authenticated"])
28+
```

composition-js/src/__tests__/compose.composeDirective.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ describe('composing custom core directives', () => {
261261
});
262262

263263
it.each([
264-
'@tag', '@inaccessible',
264+
'@tag', '@inaccessible', '@authenticated',
265265
])('federation directives that result in a hint', (directive) => {
266266
const subgraphA = generateSubgraph({
267267
name: 'subgraphA',
@@ -282,13 +282,13 @@ describe('composing custom core directives', () => {
282282
});
283283

284284
it.each([
285-
'@tag', '@inaccessible',
285+
'@tag', '@inaccessible', '@authenticated',
286286
])('federation directives (with rename) that result in a hint', (directive) => {
287287
const subgraphA = {
288288
name: 'subgraphA',
289289
typeDefs: gql`
290290
extend schema
291-
@link(url: "https://specs.apollo.dev/federation/v2.1", import: [{ name: "@key" }, { name: "@composeDirective" } , { name: "${directive}", as: "@apolloDirective" }])
291+
@link(url: "https://specs.apollo.dev/federation/v2.5", import: [{ name: "@key" }, { name: "@composeDirective" } , { name: "${directive}", as: "@apolloDirective" }])
292292
@link(url: "https://specs.apollo.dev/link/v1.0")
293293
@composeDirective(name: "@apolloDirective")
294294

composition-js/src/__tests__/compose.test.ts

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
asFed2SubgraphDocument,
33
assert,
44
buildSubgraph,
5+
DEFAULT_SUPPORTED_SUPERGRAPH_FEATURES,
56
defaultPrintOptions,
67
FEDERATION2_LINK_WITH_FULL_IMPORTS,
78
inaccessibleIdentity,
@@ -4008,4 +4009,223 @@ describe('composition', () => {
40084009
assertCompositionSuccess(result);
40094010
});
40104011
});
4012+
4013+
describe('@authenticated', () => {
4014+
// We need to override the default supported features to include the
4015+
// @authenticated feature, since it's not part of the default supported
4016+
// features.
4017+
const supportedFeatures = new Set([
4018+
...DEFAULT_SUPPORTED_SUPERGRAPH_FEATURES,
4019+
'https://specs.apollo.dev/authenticated/v0.1',
4020+
]);
4021+
4022+
it('comprehensive locations', () => {
4023+
const onObject = {
4024+
typeDefs: gql`
4025+
type Query {
4026+
object: AuthenticatedObject!
4027+
}
4028+
4029+
type AuthenticatedObject @authenticated {
4030+
field: Int!
4031+
}
4032+
`,
4033+
name: 'on-object',
4034+
};
4035+
4036+
const onInterface = {
4037+
typeDefs: gql`
4038+
type Query {
4039+
interface: AuthenticatedInterface!
4040+
}
4041+
4042+
interface AuthenticatedInterface @authenticated {
4043+
field: Int!
4044+
}
4045+
`,
4046+
name: 'on-interface',
4047+
};
4048+
4049+
const onInterfaceObject = {
4050+
typeDefs: gql`
4051+
type AuthenticatedInterfaceObject
4052+
@interfaceObject
4053+
@key(fields: "id")
4054+
@authenticated
4055+
{
4056+
id: String!
4057+
}
4058+
`,
4059+
name: 'on-interface-object',
4060+
}
4061+
4062+
const onScalar = {
4063+
typeDefs: gql`
4064+
scalar AuthenticatedScalar @authenticated
4065+
4066+
# This needs to exist in at least one other subgraph from where it's defined
4067+
# as an @interfaceObject (so arbitrarily adding it here). We don't actually
4068+
# apply @authenticated to this one since we want to see it propagate even
4069+
# when it's not applied in all locations.
4070+
interface AuthenticatedInterfaceObject @key(fields: "id") {
4071+
id: String!
4072+
}
4073+
`,
4074+
name: 'on-scalar',
4075+
};
4076+
4077+
const onEnum = {
4078+
typeDefs: gql`
4079+
enum AuthenticatedEnum @authenticated {
4080+
A
4081+
B
4082+
}
4083+
`,
4084+
name: 'on-enum',
4085+
};
4086+
4087+
const onRootField = {
4088+
typeDefs: gql`
4089+
type Query {
4090+
authenticatedRootField: Int! @authenticated
4091+
}
4092+
`,
4093+
name: 'on-root-field',
4094+
};
4095+
4096+
const onObjectField = {
4097+
typeDefs: gql`
4098+
type Query {
4099+
objectWithField: ObjectWithAuthenticatedField!
4100+
}
4101+
4102+
type ObjectWithAuthenticatedField {
4103+
field: Int! @authenticated
4104+
}
4105+
`,
4106+
name: 'on-object-field',
4107+
};
4108+
4109+
const onEntityField = {
4110+
typeDefs: gql`
4111+
type Query {
4112+
entityWithField: EntityWithAuthenticatedField!
4113+
}
4114+
4115+
type EntityWithAuthenticatedField @key(fields: "id") {
4116+
id: ID!
4117+
field: Int! @authenticated
4118+
}
4119+
`,
4120+
name: 'on-entity-field',
4121+
};
4122+
4123+
const result = composeAsFed2Subgraphs([
4124+
onObject,
4125+
onInterface,
4126+
onInterfaceObject,
4127+
onScalar,
4128+
onEnum,
4129+
onRootField,
4130+
onObjectField,
4131+
onEntityField,
4132+
], { supportedFeatures });
4133+
assertCompositionSuccess(result);
4134+
4135+
const authenticatedElements = [
4136+
"AuthenticatedObject",
4137+
"AuthenticatedInterface",
4138+
"AuthenticatedInterfaceObject",
4139+
"AuthenticatedScalar",
4140+
"AuthenticatedEnum",
4141+
"Query.authenticatedRootField",
4142+
"ObjectWithAuthenticatedField.field",
4143+
"EntityWithAuthenticatedField.field",
4144+
];
4145+
4146+
for (const element of authenticatedElements) {
4147+
expect(
4148+
result.schema
4149+
.elementByCoordinate(element)
4150+
?.hasAppliedDirective("authenticated")
4151+
).toBeTruthy();
4152+
}
4153+
});
4154+
4155+
it('applies @authenticated on types as long as it is used once', () => {
4156+
const a1 = {
4157+
typeDefs: gql`
4158+
type Query {
4159+
a: A
4160+
}
4161+
type A @key(fields: "id") @authenticated {
4162+
id: String!
4163+
a1: String
4164+
}
4165+
`,
4166+
name: 'a1',
4167+
};
4168+
const a2 = {
4169+
typeDefs: gql`
4170+
type A @key(fields: "id") {
4171+
id: String!
4172+
a2: String
4173+
}
4174+
`,
4175+
name: 'a2',
4176+
};
4177+
4178+
// checking composition in either order (not sure if this is necessary but
4179+
// it's not hurting anything)
4180+
const result1 = composeAsFed2Subgraphs([a1, a2], { supportedFeatures });
4181+
const result2 = composeAsFed2Subgraphs([a2, a1], { supportedFeatures });
4182+
assertCompositionSuccess(result1);
4183+
assertCompositionSuccess(result2);
4184+
4185+
expect(result1.schema.type('A')?.hasAppliedDirective('authenticated')).toBeTruthy();
4186+
expect(result2.schema.type('A')?.hasAppliedDirective('authenticated')).toBeTruthy();
4187+
});
4188+
4189+
it('validation error on incompatible directive definition', () => {
4190+
const invalidDefinition = {
4191+
typeDefs: gql`
4192+
directive @authenticated on ENUM_VALUE
4193+
4194+
type Query {
4195+
a: Int
4196+
}
4197+
4198+
enum E {
4199+
A @authenticated
4200+
}
4201+
`,
4202+
name: 'invalidDefinition',
4203+
};
4204+
const result = composeAsFed2Subgraphs([invalidDefinition], { supportedFeatures });
4205+
expect(errors(result)[0]).toEqual([
4206+
"DIRECTIVE_DEFINITION_INVALID",
4207+
"[invalidDefinition] Invalid definition for directive \"@authenticated\": \"@authenticated\" should have locations FIELD_DEFINITION, OBJECT, INTERFACE, SCALAR, ENUM, but found (non-subset) ENUM_VALUE",
4208+
]);
4209+
});
4210+
4211+
it('validation error on invalid application', () => {
4212+
const invalidApplication = {
4213+
typeDefs: gql`
4214+
type Query {
4215+
a: Int
4216+
}
4217+
4218+
enum E {
4219+
A @authenticated
4220+
}
4221+
`,
4222+
name: 'invalidApplication',
4223+
};
4224+
const result = composeAsFed2Subgraphs([invalidApplication], { supportedFeatures });
4225+
expect(errors(result)[0]).toEqual([
4226+
"INVALID_GRAPHQL",
4227+
"[invalidApplication] Directive \"@authenticated\" may not be used on ENUM_VALUE.",
4228+
]);
4229+
});
4230+
});
40114231
});

composition-js/src/compose.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,8 @@ export interface CompositionSuccess {
3636

3737
export interface CompositionOptions {
3838
sdlPrintOptions?: PrintOptions;
39-
40-
41-
allowedFieldTypeMergingSubtypingRules?: SubtypingRule[]
39+
allowedFieldTypeMergingSubtypingRules?: SubtypingRule[];
40+
supportedFeatures?: Set<string>;
4241
}
4342

4443
function validateCompositionOptions(options: CompositionOptions) {
@@ -66,7 +65,7 @@ export function compose(subgraphs: Subgraphs, options: CompositionOptions = {}):
6665
return { errors: mergeResult.errors };
6766
}
6867

69-
const supergraph = new Supergraph(mergeResult.supergraph);
68+
const supergraph = new Supergraph(mergeResult.supergraph, options.supportedFeatures);
7069
const supergraphQueryGraph = buildSupergraphAPIQueryGraph(supergraph);
7170
const federatedQueryGraph = buildFederatedQueryGraph(supergraph, false);
7271
const { errors, hints } = validateGraphComposition(supergraph.schema, supergraphQueryGraph, federatedQueryGraph);

composition-js/src/composeDirectiveManager.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const DISALLOWED_IDENTITIES = [
6262
'https://specs.apollo.dev/tag',
6363
'https://specs.apollo.dev/inaccessible',
6464
'https://specs.apollo.dev/federation',
65+
'https://specs.apollo.dev/authenticated',
6566
];
6667

6768
export class ComposeDirectiveManager {
@@ -170,6 +171,7 @@ export class ComposeDirectiveManager {
170171
const directivesComposedByDefault = [
171172
sg.metadata().tagDirective(),
172173
sg.metadata().inaccessibleDirective(),
174+
sg.metadata().authenticatedDirective(),
173175
].map(d => d.name);
174176
if (directivesComposedByDefault.includes(directive.name)) {
175177
this.pushHint(new CompositionHint(

gateway-js/src/__tests__/gateway/endToEnd.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { startSubgraphsAndGateway, Services } from './testUtils'
66
import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache';
77
import { QueryPlan } from '@apollo/query-planner';
88
import { createHash } from '@apollo/utils.createhash';
9+
import { ApolloGateway, LocalCompose } from '@apollo/gateway';
910

1011
function approximateObjectSize<T>(obj: T): number {
1112
return Buffer.byteLength(JSON.stringify(obj), 'utf8');
@@ -483,4 +484,31 @@ describe('end-to-end features', () => {
483484
}
484485
`);
485486
});
487+
488+
it('explicitly errors on @authenticated import', async () => {
489+
const subgraphA = {
490+
name: 'A',
491+
typeDefs: gql`
492+
extend schema
493+
@link(
494+
url: "https://specs.apollo.dev/federation/v2.5"
495+
import: ["@authenticated"]
496+
)
497+
498+
type Query {
499+
a: Int @authenticated
500+
}
501+
`,
502+
};
503+
504+
const gateway = new ApolloGateway({
505+
supergraphSdl: new LocalCompose({
506+
localServiceList: [subgraphA],
507+
}),
508+
});
509+
510+
await expect(gateway.load()).rejects.toThrowError(
511+
"feature https://specs.apollo.dev/authenticated/v0.1 is for: SECURITY but is unsupported"
512+
);
513+
});
486514
});

0 commit comments

Comments
 (0)