Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
changeKind: feature
packages:
- "@typespec/http"
- "@typespec/openapi3"
---

Add scope support to `OpenIdConnectAuth`. The model now accepts an optional `Scopes` template parameter (`OpenIdConnectAuth<ConnectUrl, Scopes>`) and the OpenAPI3 emitter emits those scopes on each operation's `openIdConnect` security requirement. The scheme object itself remains unchanged (scopes are discovered via the `openIdConnectUrl`). Existing `OpenIdConnectAuth<Url>` usages are unaffected.
8 changes: 7 additions & 1 deletion packages/http/lib/auth.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,19 @@ model ClientCredentialsFlow {
* ```http
* https://server.com/.well-known/openid-configuration
* ```
*
* @template ConnectUrl The openIdConnectUrl, where the OpenID provider publishes its discovery metadata. It can be specified relative to the server URL.
* @template Scopes The scope names required for operations that use this scheme.
*/
model OpenIdConnectAuth<ConnectUrl extends string> {
model OpenIdConnectAuth<ConnectUrl extends string, Scopes extends string[] = []> {
/** Auth type */
type: AuthType.openIdConnect;

/** Connect url. It can be specified relative to the server URL */
openIdConnectUrl: ConnectUrl;

/** Scope names required for operations that use this authentication scheme. */
scopes: Scopes;
}

/**
Expand Down
14 changes: 13 additions & 1 deletion packages/http/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,12 @@ function makeHttpAuthRef(local: HttpAuth, reference: HttpAuth): HttpAuthRef {
} else if (reference.type === "noAuth") {
return { kind: "noAuth", auth: reference };
} else {
return { kind: "any", auth: reference };
// Requirement scopes are read from the per-option (`local`) scheme so that
// operation-level `@useAuth` can request a different scope subset than the
// service default. Only openIdConnect currently surfaces scopes; the ref is
// scheme-agnostic so other scheme types can opt in without a new ref kind.
const scopes = local.type === "openIdConnect" ? (local.scopes ?? []) : [];
return { kind: "any", auth: reference, scopes };
}
}

Expand Down Expand Up @@ -161,5 +166,12 @@ function authsAreEqual(scheme1: HttpAuth, scheme2: HttpAuth): boolean {
if (withoutModel1.type === "oauth2" && withoutModel2.type === "oauth2") {
return deepEquals(ignoreScopes(withoutModel1), ignoreScopes(withoutModel2));
}
// Scopes live on the security requirement, not on the scheme identity, so two
// openIdConnect schemes that differ only by scopes are the same scheme and
// must dedupe to a single id (per-requirement scopes are still carried on the
// auth ref). No scope merge is needed because the scheme object lists no scopes.
if (withoutModel1.type === "openIdConnect" && withoutModel2.type === "openIdConnect") {
return deepEquals({ ...withoutModel1, scopes: [] }, { ...withoutModel2, scopes: [] });
}
return deepEquals(withoutModel1, withoutModel2);
}
10 changes: 9 additions & 1 deletion packages/http/src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,15 @@ function extractHttpAuthentication(
const auth =
result.type === "oauth2"
? extractOAuth2Auth(modelType, result)
: { ...result, model: modelType };
: {
...result,
// OpenID Connect requirement scopes come from the `scopes` tuple on the
// model. Normalize to an array so downstream resolution can rely on it.
...(result.type === "openIdConnect" && {
scopes: Array.isArray((result as any).scopes) ? (result as any).scopes : [],
}),
model: modelType,
};
return [
{
...auth,
Expand Down
8 changes: 8 additions & 0 deletions packages/http/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ export interface OAuth2Scope {
export interface OpenIDConnectAuth extends HttpAuthBase {
type: "openIdConnect";
openIdConnectUrl: string;
/** Scope names required for operations that use this scheme. */
scopes: string[];
}

/**
Expand All @@ -203,6 +205,12 @@ export type HttpAuthRef = AnyHttpAuthRef | OAuth2HttpAuthRef | NoHttpAuthRef;
export interface AnyHttpAuthRef {
readonly kind: "any";
readonly auth: HttpAuth;
/**
* Scope names required for this scheme in the containing auth option. Empty
* for schemes that do not carry scopes. Populated for `openIdConnect`; kept
* scheme-agnostic so other scheme types can carry scopes without a new ref kind.
*/
readonly scopes: string[];
}

export interface NoHttpAuthRef {
Expand Down
29 changes: 29 additions & 0 deletions packages/http/test/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,32 @@ it("should deduplicate scopes when multiple flows share the same scopes", async
expect(oauthRef.scopes).toHaveLength(1);
}
});

it("carries scopes on the auth ref for OpenIdConnectAuth", async () => {
const { program } = await Tester.compile(`
model oidc<Scopes extends string[]>
is OpenIdConnectAuth<"https://example.org/.well-known/openid-configuration", Scopes>;

@useAuth(oidc<["read", "write"]>)
@test op testOp(): void;
`);

const [services] = getAllHttpServices(program);
const httpService = services[0];
const auth = resolveAuthentication(httpService);

const testOp = httpService.operations.find((op) => op.operation.name === "testOp");
ok(testOp, "Should find test operation");

const operationAuth = auth.operationsAuth.get(testOp.operation);
ok(operationAuth, "Should have operation auth");

const ref = operationAuth.options[0].all[0];
// OIDC routes through the scheme-agnostic `any` ref which carries the
// requirement scopes.
strictEqual(ref.kind, "any");
strictEqual(ref.auth.type, "openIdConnect");
if (ref.kind === "any") {
expect(ref.scopes).toEqual(["read", "write"]);
}
});
4 changes: 3 additions & 1 deletion packages/openapi3/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1985,7 +1985,9 @@ function createOAPIEmitter(
securityOption[httpAuthRef.auth.id] = httpAuthRef.scopes;
continue;
default:
securityOption[httpAuthRef.auth.id] = [];
// Requirement scopes for any scheme that carries them (e.g.
// openIdConnect). Schemes without scopes resolve to an empty array.
securityOption[httpAuthRef.auth.id] = httpAuthRef.scopes;
}
}
return securityOption;
Expand Down
44 changes: 44 additions & 0 deletions packages/openapi3/test/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,50 @@ worksFor(supportedVersions, ({ diagnoseOpenApiFor, openApiFor }) => {
deepStrictEqual(res.security, [{ OpenIdConnectAuth: [] }]);
});

it("set openId auth with scopes", async () => {
const res = await openApiFor(
`
@service
@useAuth(OpenIdConnectAuth<"https://api.example.com/openid", ["read", "write"]>)
namespace MyService {}
`,
);
// The scheme object must NOT list scopes (clients discover them via the
// openIdConnectUrl); only the security requirement lists required scopes.
expect(res.components.securitySchemes).toEqual({
OpenIdConnectAuth: {
type: "openIdConnect",
openIdConnectUrl: "https://api.example.com/openid",
description: expect.stringMatching(/^OpenID Connect/),
},
});
deepStrictEqual(res.security, [{ OpenIdConnectAuth: ["read", "write"] }]);
});

it("set openId auth scopes at the operation level", async () => {
const res = await openApiFor(
`
@service
@useAuth(OpenIdConnectAuth<"https://api.example.com/openid", ["read"]>)
namespace MyService {
@route("/a")
@useAuth(OpenIdConnectAuth<"https://api.example.com/openid", ["read", "write"]>)
op a(): void;
}
`,
);
// Same OIDC scheme (differs only by scopes) must dedupe to a single scheme.
expect(res.components.securitySchemes).toEqual({
OpenIdConnectAuth: {
type: "openIdConnect",
openIdConnectUrl: "https://api.example.com/openid",
description: expect.stringMatching(/^OpenID Connect/),
},
});
deepStrictEqual(res.security, [{ OpenIdConnectAuth: ["read"] }]);
deepStrictEqual(res.paths["/a"].get.security, [{ OpenIdConnectAuth: ["read", "write"] }]);
});

it("set a unsupported auth", async () => {
const diagnostics = await diagnoseOpenApiFor(
`
Expand Down
25 changes: 25 additions & 0 deletions website/src/content/docs/docs/libraries/http/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,31 @@ OAuth relies on authentication scenarios called flows, which allow the resource
For this purpose, an OAuth 2.0 server issues access tokens that client applications can use to access protected resources on behalf of the resource owner.
For more information about OAuth 2.0, see [oauth.net](https://oauth.net) and [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749).

### `OpenIdConnectAuth<ConnectUrl extends string, Scopes extends string[] = []>`

OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0. The provider publishes its metadata (including its authorization and token endpoints) at a well-known discovery URL, so the scheme only needs that URL rather than explicit flow endpoints.

```typespec
@useAuth(OpenIdConnectAuth<"https://api.example.com/.well-known/openid-configuration">)
```

Operations can require specific scopes via the second template argument. As with OAuth2, define a template alias so different operations can request different scopes without repeating the discovery URL:

```tsp
alias MyOidc<Scopes extends string[]> = OpenIdConnectAuth<
"https://api.example.com/.well-known/openid-configuration",
Scopes
>;

// Use OpenID Connect with the "read" scope
@useAuth(MyOidc<["read"]>)
op list(): string[];

// Use OpenID Connect with the "write" scope
@useAuth(MyOidc<["write"]>)
op write(value: string): void;
```

## Application hierarchy

The `@useAuth` decorator can be used on a service namespace, sub-namespace, interface or operation. The security scheme specified will be applied to all operations contained in the type.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -567,21 +567,23 @@ https://server.com/.well-known/openid-configuration
```

```typespec
model TypeSpec.Http.OpenIdConnectAuth<ConnectUrl>
model TypeSpec.Http.OpenIdConnectAuth<ConnectUrl, Scopes>
```

#### Template Parameters

| Name | Description |
| ---------- | ----------- |
| ConnectUrl | |
| Name | Description |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------- |
| ConnectUrl | The openIdConnectUrl, where the OpenID provider publishes its discovery metadata. It can be specified relative to the server URL. |
| Scopes | The scope names required for operations that use this scheme. |

#### Properties

| Name | Type | Description |
| ---------------- | -------------------------------------- | ----------------------------------------------------------- |
| type | `TypeSpec.Http.AuthType.openIdConnect` | Auth type |
| openIdConnectUrl | `ConnectUrl` | Connect url. It can be specified relative to the server URL |
| Name | Type | Description |
| ---------------- | -------------------------------------- | ------------------------------------------------------------------------ |
| type | `TypeSpec.Http.AuthType.openIdConnect` | Auth type |
| openIdConnectUrl | `ConnectUrl` | Connect url. It can be specified relative to the server URL |
| scopes | `Scopes` | Scope names required for operations that use this authentication scheme. |

### `PasswordFlow` {#TypeSpec.Http.PasswordFlow}

Expand Down
Loading