forked from microsoft/typespec
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Operation level authentication and scopes (microsoft#2901)
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
1 parent
aa6e53f
commit d2d397c
Showing
24 changed files
with
776 additions
and
124 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
Oops, something went wrong.