Skip to content

Commit

Permalink
Operation level authentication and scopes (microsoft#2901)
Browse files Browse the repository at this point in the history
Hi! πŸ––πŸ» 
This PR resolves microsoft#2624 by implementing the [design
doc](https://gist.github.com/timotheeguerin/56690786e61a436710dd647de9febc0f),
but in its initial form:
- `@useAuth` can now be applied not only to service namespace, but to
interfaces and operations as well. Its arguments override all
authentication, which was set for enclosing scopes.
- OAuth2 scopes can now be set at operation level (though, the code
doing this in OpenAPI emitter is a bit clunky).
- New `NoAuth` authentication option allows to declare optional
authentication (`NoAuth | AnyOtherAuth`) or override authentication to
none in nested scopes.

This implementation does not introduce new `@authScopes` decorator as
design doc comments suggest, and here's why:

1. It does not compose well with `@useAuth` at operation level. For
example
```
...
@useAuth(BasicAuth)
@authScopes(MyOauth2, ["read"])
op gogo(): void
```
Should that be equivalent to `BasicAuth | MyOauth2`, or to `[BasicAuth,
MyOauth2]`?

2. Introducing new decorator would increase complexity, but (imho) it
would not reduce the amount of boilerplate:
```
alias MyOAuth2 = OAuth2Auth<{ ... }>;

@useAuth(MyOAuth2)
@authAcopes(MyOauth2, ["read"])
@service
namepsace Foo;
```
vs
```
model MyOAuth2Flow<T extends string[]>  {  ...  };
alias MyOauth2<T extends string[]> = Oauth2Auth<[MyOauth2Flow[T]]>

@useAuth(MyOAuth2<["read"]>)
@service
namepsace Foo
```

I would be happy to hear any feedback and apply suggested changes.

And thanks for a convenient development setup and thorough test
coverage!

---------

Co-authored-by: Timothee Guerin <timothee.guerin@outlook.com>
  • Loading branch information
2 people authored and markcowl committed Mar 8, 2024
1 parent d9657f5 commit 7a178fe
Show file tree
Hide file tree
Showing 24 changed files with 776 additions and 124 deletions.
8 changes: 8 additions & 0 deletions .chronus/changes/flexible-auth-2024-1-9-0-8-11.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: feature
packages:
- "@typespec/http"
---

Add ability to sepcify authentication and different scopes per operation
8 changes: 8 additions & 0 deletions .chronus/changes/flexible-auth-2024-1-9-0-8-12.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: internal
packages:
- "@typespec/openapi3"
---

Add support for per operation authentication and scopes
18 changes: 14 additions & 4 deletions docs/libraries/http/reference/data-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,15 @@ The URL of the requested resource has been changed permanently. The new URL is g
model TypeSpec.Http.MovedResponse
```

### `NoAuth` {#TypeSpec.Http.NoAuth}

This authentication option signifies that API is not secured at all.
It might be useful when overriding authentication on interface of operation level.

```typespec
model TypeSpec.Http.NoAuth
```

### `NoContentResponse` {#TypeSpec.Http.NoContentResponse}

There is no content to send for this request, but the headers may be useful.
Expand Down Expand Up @@ -207,14 +216,15 @@ For that purpose, an OAuth 2.0 server issues access tokens that the client appli
For more information about OAuth 2.0, see oauth.net and RFC 6749.

```typespec
model TypeSpec.Http.OAuth2Auth<Flows>
model TypeSpec.Http.OAuth2Auth<Flows, Scopes>
```

#### Template Parameters

| Name | Description |
| ----- | ---------------------------------- |
| Flows | The list of supported OAuth2 flows |
| Name | Description |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Flows | The list of supported OAuth2 flows |
| Scopes | The list of OAuth2 scopes, which are common for every flow from `Flows`. This list is combined with the scopes defined in specific OAuth2 flows. |

### `OkResponse` {#TypeSpec.Http.OkResponse}

Expand Down
4 changes: 2 additions & 2 deletions docs/libraries/http/reference/decorators.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,15 +402,15 @@ op create(): {@statusCode: 201 | 202}

### `@useAuth` {#@TypeSpec.Http.useAuth}

Specify this service authentication. See the [documentation in the Http library](https://typespec.io/docs/libraries/http/authentication) for full details.
Specify authentication for a whole service or specific methods. See the [documentation in the Http library](https://typespec.io/docs/libraries/http/authentication) for full details.

```typespec
@TypeSpec.Http.useAuth(auth: {} | Union | {}[])
```

#### Target

`Namespace`
`union Namespace | Interface | Operation`

#### Parameters

Expand Down
1 change: 1 addition & 0 deletions docs/libraries/http/reference/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ npm install --save-peer @typespec/http
- [`ImplicitFlow`](./data-types.md#TypeSpec.Http.ImplicitFlow)
- [`LocationHeader`](./data-types.md#TypeSpec.Http.LocationHeader)
- [`MovedResponse`](./data-types.md#TypeSpec.Http.MovedResponse)
- [`NoAuth`](./data-types.md#TypeSpec.Http.NoAuth)
- [`NoContentResponse`](./data-types.md#TypeSpec.Http.NoContentResponse)
- [`NotFoundResponse`](./data-types.md#TypeSpec.Http.NotFoundResponse)
- [`NotModifiedResponse`](./data-types.md#TypeSpec.Http.NotModifiedResponse)
Expand Down
4 changes: 2 additions & 2 deletions packages/http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,15 +447,15 @@ op create(): {@statusCode: 201 | 202}

#### `@useAuth`

Specify this service authentication. See the [documentation in the Http library](https://typespec.io/docs/libraries/http/authentication) for full details.
Specify authentication for a whole service or specific methods. See the [documentation in the Http library](https://typespec.io/docs/libraries/http/authentication) for full details.

```typespec
@TypeSpec.Http.useAuth(auth: {} | Union | {}[])
```

##### Target

`Namespace`
`union Namespace | Interface | Operation`

##### Parameters

Expand Down
26 changes: 21 additions & 5 deletions packages/http/lib/auth.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ enum AuthType {

@doc("OpenID connect")
openIdConnect,

@doc("Empty auth")
noAuth,
}

/**
Expand Down Expand Up @@ -105,14 +108,18 @@ model ApiKeyAuth<Location extends ApiKeyLocation, Name extends string> {
* For more information about OAuth 2.0, see oauth.net and RFC 6749.
*
* @template Flows The list of supported OAuth2 flows
* @template Scopes The list of OAuth2 scopes, which are common for every flow from `Flows`. This list is combined with the scopes defined in specific OAuth2 flows.
*/
@doc("")
model OAuth2Auth<Flows extends OAuth2Flow[]> {
model OAuth2Auth<Flows extends OAuth2Flow[], Scopes extends string[] = []> {
@doc("OAuth2 authentication")
type: AuthType.oauth2;

@doc("Supported OAuth2 flows")
flows: Flows;

@doc("Oauth2 scopes of every flow. Overridden by scope definitions in specific flows")
defaultScopes: Scopes;
}

@doc("Describes the OAuth2 flow type")
Expand Down Expand Up @@ -147,7 +154,7 @@ model AuthorizationCodeFlow {
refreshUrl?: string;

@doc("list of scopes for the credential")
scopes: string[];
scopes?: string[];
}

@doc("Implicit flow")
Expand All @@ -162,7 +169,7 @@ model ImplicitFlow {
refreshUrl?: string;

@doc("list of scopes for the credential")
scopes: string[];
scopes?: string[];
}

@doc("Resource Owner Password flow")
Expand All @@ -177,7 +184,7 @@ model PasswordFlow {
refreshUrl?: string;

@doc("list of scopes for the credential")
scopes: string[];
scopes?: string[];
}

@doc("Client credentials flow")
Expand All @@ -192,7 +199,7 @@ model ClientCredentialsFlow {
refreshUrl?: string;

@doc("list of scopes for the credential")
scopes: string[];
scopes?: string[];
}

/**
Expand All @@ -212,3 +219,12 @@ model OpenIdConnectAuth<ConnectUrl extends valueof string> {
/** Connect url. It can be specified relative to the server URL */
openIdConnectUrl: ConnectUrl;
}

/**
* This authentication option signifies that API is not secured at all.
* It might be useful when overriding authentication on interface of operation level.
*/
@doc("")
model NoAuth {
type: AuthType.noAuth;
}
4 changes: 2 additions & 2 deletions packages/http/lib/http-decorators.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ extern dec server(
);

/**
* Specify this service authentication. See the [documentation in the Http library](https://typespec.io/docs/libraries/http/authentication) for full details.
* Specify authentication for a whole service or specific methods. See the [documentation in the Http library](https://typespec.io/docs/libraries/http/authentication) for full details.
*
* @param auth Authentication configuration. Can be a single security scheme, a union(either option is valid authentication) or a tuple (must use all authentication together)
* @example
Expand All @@ -213,7 +213,7 @@ extern dec server(
* namespace PetStore;
* ```
*/
extern dec useAuth(target: Namespace, auth: {} | Union | {}[]);
extern dec useAuth(target: Namespace | Interface | Operation, auth: {} | Union | {}[]);

/**
* Specify if inapplicable metadata should be included in the payload for the given entity.
Expand Down
185 changes: 185 additions & 0 deletions packages/http/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { Operation, Program } from "@typespec/compiler";
import { deepClone, deepEquals } from "@typespec/compiler/utils";
import { HttpStateKeys } from "./lib.js";
import {
Authentication,
HttpAuth,
HttpService,
NoAuth,
OAuth2Flow,
OAuth2Scope,
Oauth2Auth,
} from "./types.js";

export function getAuthenticationForOperation(
program: Program,
operation: Operation
): Authentication | undefined {
const operationAuth = program.stateMap(HttpStateKeys.authentication).get(operation);
if (operationAuth === undefined && operation.interface !== undefined) {
const interfaceAuth = program.stateMap(HttpStateKeys.authentication).get(operation.interface);
return interfaceAuth;
}
return operationAuth;
}

export type HttpAuthRef = AnyHttpAuthRef | OAuth2HttpAuthRef | NoHttpAuthRef;

export interface AnyHttpAuthRef {
readonly kind: "any";
readonly auth: HttpAuth;
}

export interface NoHttpAuthRef {
readonly kind: "noAuth";
readonly auth: NoAuth;
}

/* Holder of this reference needs only a `scopes` subset of all scopes defined at `auth` */
export interface OAuth2HttpAuthRef {
readonly kind: "oauth2";
readonly auth: Oauth2Auth<OAuth2Flow[]>;
readonly scopes: string[];
}

export interface AuthenticationReference {
/**
* Either one of those options can be used independently to authenticate.
*/
readonly options: AuthenticationOptionReference[];
}

export interface AuthenticationOptionReference {
/**
* For this authentication option all the given auth have to be used together.
*/
readonly all: HttpAuthRef[];
}

export interface HttpServiceAuthentication {
/**
* All the authentication schemes used in this service.
* Some might only be used in certain operations.
*/
readonly schemes: HttpAuth[];

/**
* Default authentication for operations in this service.
*/
readonly defaultAuth: AuthenticationReference;

/**
* Authentication overrides for individual operations.
*/
readonly operationsAuth: Map<Operation, AuthenticationReference>;
}

export function resolveAuthentication(service: HttpService): HttpServiceAuthentication {
let schemes: Record<string, HttpAuth> = {};
let defaultAuth: AuthenticationReference = { options: [] };
const operationsAuth: Map<Operation, AuthenticationReference> = new Map();

if (service.authentication) {
const { newServiceSchemes, authOptions } = gatherAuth(service.authentication, {});
schemes = newServiceSchemes;
defaultAuth = authOptions;
}

for (const op of service.operations) {
if (op.authentication) {
const { newServiceSchemes, authOptions } = gatherAuth(op.authentication, schemes);
schemes = newServiceSchemes;
operationsAuth.set(op.operation, authOptions);
}
}

return { schemes: Object.values(schemes), defaultAuth, operationsAuth };
}

function gatherAuth(
authentication: Authentication,
serviceSchemes: Record<string, HttpAuth>
): {
newServiceSchemes: Record<string, HttpAuth>;
authOptions: AuthenticationReference;
} {
const newServiceSchemes: Record<string, HttpAuth> = serviceSchemes;
const authOptions: AuthenticationReference = { options: [] };
for (const option of authentication.options) {
const authOption: AuthenticationOptionReference = { all: [] };
for (const optionScheme of option.schemes) {
const serviceScheme = serviceSchemes[optionScheme.id];
let newServiceScheme = optionScheme;
if (serviceScheme) {
// If we've seen a different scheme by this id,
// Make sure to not overwrite it
if (!authsAreEqual(serviceScheme, optionScheme)) {
while (serviceSchemes[newServiceScheme.id]) {
newServiceScheme.id = newServiceScheme.id + "_";
}
}
// Merging scopes when encountering the same Oauth2 scheme
else if (serviceScheme.type === "oauth2" && optionScheme.type === "oauth2") {
const x = mergeOAuthScopes(serviceScheme, optionScheme);
newServiceScheme = x;
}
}
const httpAuthRef = makeHttpAuthRef(optionScheme, newServiceScheme);
newServiceSchemes[newServiceScheme.id] = newServiceScheme;
authOption.all.push(httpAuthRef);
}
authOptions.options.push(authOption);
}
return { newServiceSchemes, authOptions };
}

function makeHttpAuthRef(local: HttpAuth, reference: HttpAuth): HttpAuthRef {
if (reference.type === "oauth2" && local.type === "oauth2") {
const scopes: string[] = [];
for (const flow of local.flows) {
scopes.push(...flow.scopes.map((x) => x.value));
}
return { kind: "oauth2", auth: reference, scopes: scopes };
} else if (reference.type === "noAuth") {
return { kind: "noAuth", auth: reference };
} else {
return { kind: "any", auth: reference };
}
}

function mergeOAuthScopes<Flows extends OAuth2Flow[]>(
scheme1: Oauth2Auth<Flows>,
scheme2: Oauth2Auth<Flows>
): Oauth2Auth<Flows> {
const flows = deepClone(scheme1.flows);
flows.forEach((flow1, i) => {
const flow2 = scheme2.flows[i];
const scopes = Array.from(new Set(flow1.scopes.concat(flow2.scopes)));
flows[i].scopes = scopes;
});
return {
...scheme1,
flows,
};
}

function setOauth2Scopes<Flows extends OAuth2Flow[]>(
scheme: Oauth2Auth<Flows>,
scopes: OAuth2Scope[]
): Oauth2Auth<Flows> {
const flows: Flows = deepClone(scheme.flows);
flows.forEach((flow) => {
flow.scopes = scopes;
});
return {
...scheme,
flows,
};
}

function authsAreEqual(scheme1: HttpAuth, scheme2: HttpAuth): boolean {
if (scheme1.type === "oauth2" && scheme2.type === "oauth2") {
return deepEquals(setOauth2Scopes(scheme1, []), setOauth2Scopes(scheme2, []));
}
return deepEquals(scheme1, scheme2);
}
Loading

0 comments on commit 7a178fe

Please sign in to comment.